diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1e1bcd3..e72684c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -7,35 +7,45 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Build & Test Commands ```bash -cargo build # Debug build -cargo build --release # Optimized release build (LTO, stripped) -cargo test --all-features -- --test-threads=1 # Run CI-equivalent tests -cargo test # Run a single test by name -cargo test --test integration_test # Run only integration tests -cargo fmt --all -- --check # Check formatting -cargo clippy --all-targets --all-features -- -D warnings # Lint (zero warnings enforced) +cargo build --workspace # Debug build +cargo build --release --workspace # Optimized release (LTO, stripped) +cargo test --workspace # Run all tests +cargo test -p git-same-core # Tests for the engine crate only +cargo test -p git-same # Tests for the CLI crate only +cargo test --workspace # Run a single test by name +cargo test -p git-same --test integration_test # Run only integration tests +cargo fmt --all -- --check # Check formatting +cargo clippy --workspace --all-targets --all-features -- -D warnings # Lint ``` -Logging is controlled via `GISA_LOG` env var (e.g., `GISA_LOG=debug cargo run -- sync`). +Logging is controlled via `GISA_LOG` env var (e.g., `GISA_LOG=debug cargo run -p git-same -- sync`). ## Architecture Git-Same is a Rust CLI + TUI tool that discovers GitHub org/repo structures and mirrors them locally with parallel cloning and syncing. -**Binary aliases:** Cargo defines the primary `git-same` binary at `src/main.rs`. Installers create `gitsame`, `gitsa`, and `gisa` symlinks from `toolkit/packaging/binary-aliases.txt`. +**Workspace layout:** the project is a Cargo workspace with two member crates: + +- `git-same-core` (`crates/git-same-core/`) — the engine library. No UI dependencies (no clap, ratatui, crossterm). Holds discovery, clone/sync, IPC, status scanning, and shared types. +- `git-same` (lives at `crates/git-same-cli/` on disk; the directory name and package name intentionally diverge so `cargo install git-same` keeps working as it has since pre-3.x) — the CLI binary + TUI. Depends on `git-same-core`. Owns clap parsing, the TUI screens, the setup wizard, and command handlers. The produced binary is named `git-same` (per `[[bin]]` name) so installer aliases (`gisa`, `gitsa`, `gitsame`) and `target/release/git-same` are unchanged from the pre-split layout. + +**Binary aliases:** `git-same`, `gitsame`, `gitsa`, `gisa` — all resolve to the binary built from `crates/git-same-cli/src/main.rs`. **Dual mode:** Running with a subcommand (`gisa sync`) uses the CLI path. Running without a subcommand (`gisa`) launches the interactive TUI. -**CLI flow:** CLI parsing (`src/cli.rs`) → `main.rs` routes to command handler → handler orchestrates modules. +**macOS host strategy:** Two macOS host apps coexist. The Tauri host (`crates/git-same-app/`, Svelte + TypeScript + Vite) is the primary GUI shipped via the cask. The SwiftUI host (`macos/GitSameSwiftApp/`) is intentionally kept as a fallback per the Phase C plan's "perfect macOS feel" escape hatch (`.context/plans/phase-c-tauri-app.md` §2). **Do not delete `macos/GitSameSwiftApp/` without explicit approval** — this overrides the migration plan's earlier "delete in Phase C" instruction. -**Commands:** `init`, `setup`, `sync`, `status`, `scan`, `workspace {list,default}`, `reset`. +**CLI flow:** CLI parsing (`crates/git-same-cli/src/cli.rs`) → `main.rs` routes to command handler → handler orchestrates engine modules from `git-same-core`. -### Core modules +**Commands:** `init`, `setup`, `sync`, `status`, `scan`, `workspace {list,default}`, `reset`, `monitor` (alias: `daemon`), `refresh`. + +**Why `monitor` is a CLI subcommand and not solely a Tauri-host responsibility:** the LaunchAgent invokes `gisa monitor --foreground`, non-cask installs (`cargo install`, the homebrew formula) ship only the binary, `--status` / `--stop` are the supported debugging surface, and a future Linux file-manager extension would talk to the same `gisa monitor` over the same Unix socket. The CLI handler is a thin shim (~140 lines); the loop itself lives in `git-same-core::monitor`. + +### Engine modules (`crates/git-same-core/src/`) -- **`app/`** — Top-level entry points: `app/cli/` runs the CLI subcommand path, `app/tui/` boots the interactive TUI. `main.rs` dispatches to one or the other based on whether a subcommand was given -- **`commands/`** — Per-subcommand handlers (`init`, `setup`, `sync_cmd`, `status`, `scan`, `reset`, `workspace`) plus shared `support/` helpers -- **`workflows/`** — Cross-cutting orchestration shared by CLI and TUI: `sync_workspace` (discover + clone + fetch/pull) and `status_scan` (walk local repos, collect git status) - **`auth/`** — `gh_cli.rs` obtains GitHub API tokens via `gh auth token`. `ssh.rs` exposes low-level SSH probing primitives (`SshProbeResult`, `parse_ssh_probe_output`) used by clone-time diagnostics +- **`workflows/`** — Cross-cutting orchestration: `sync_workspace` (discover + clone + fetch/pull) and `status_scan` (walk local repos, collect git status) +- **`monitor/`**: Long-running monitor loop (periodic scan + Unix-socket server) used by `gisa monitor` and reusable by host apps like the Tauri GUI - **`config/`** — TOML config parser. Default: `~/.config/git-same/config.toml`. Top-level keys: `workspaces`, `default_workspace`, plus `[clone]` and `[filters]` sections - **`discovery.rs`** — `DiscoveryOrchestrator` coordinates repo discovery via providers, applies filters, builds `ActionPlan` (what to clone vs sync) - **`operations/clone.rs`** — `CloneManager` handles concurrent cloning (configurable 1–32, default 4) @@ -45,14 +55,24 @@ Git-Same is a Rust CLI + TUI tool that discovers GitHub org/repo structures and - **`cache/`** — `discovery.rs` provides `DiscoveryCache` (TTL-based validity, persisted at `/.git-same/cache.json`); `sync_history.rs` records sync runs at `/.git-same/sync-history.json` - **`domain/`** — Domain primitives, currently `repo_path_template.rs` for resolving `{org}/{repo}` style structures - **`infra/storage/`** — Storage abstractions for workspace-local persistence -- **`setup/`** — Setup wizard state machine, shared between the CLI `setup` command and the TUI workspace-setup screen +- **`ipc/`** — Monitor ↔ Finder-extension interface (`status_file.rs`, `unix_socket.rs`) +- **`api/`** — Higher-level service helpers built on top of git/provider/config (e.g. `RepoScanService`) - **`errors/`** — Custom error hierarchy: `AppError`, `GitError`, `ProviderError` with `suggested_action()` methods - **`output/`** — `printer.rs` for verbosity-aware text output; `progress/` holds the `indicatif` progress bars (`CloneProgressBar`, `SyncProgressBar`, `DiscoveryProgressBar`) -- **`types/repo.rs`** — Core data types: `Repo`, `Org`, `ActionPlan`, `OpResult`, `OpSummary` +- **`types/`** — Core data types: `Repo`, `Org`, `ActionPlan`, `OpResult`, `OpSummary`, plus `RepoEntry`/`SyncHistoryEntry` (lifted out of the TUI in B0.1) - **`checks.rs`** — System/runtime checks (presence of `git`, `gh`, auth status, SSH access via `check_ssh_github_access`) + +### CLI / TUI modules (`crates/git-same-cli/src/`) + +- **`app/`** — Top-level entry points: `app/cli/` runs the CLI subcommand path, `app/tui/` boots the interactive TUI. `main.rs` dispatches to one or the other based on whether a subcommand was given +- **`commands/`** — Per-subcommand handlers (`init`, `setup`, `sync_cmd`, `status`, `scan`, `reset`, `workspace`, `monitor`, `refresh`) plus shared `support/` helpers +- **`setup/`** — Setup wizard state machine + ratatui rendering, shared between the CLI `setup` command and the TUI workspace-setup screen (gated by the `tui` feature) +- **`tui/`** — Ratatui-based TUI (gated by the `tui` feature) +- **`cli.rs`** — clap derive types - **`banner.rs`** — CLI banner rendering +- **`bin/gen_completions.rs`, `bin/gen_manpage.rs`** — Release-only helpers gated by the `release-tools` feature -### TUI module (`src/tui/`, feature-gated behind `tui`) +### TUI module (`crates/git-same-cli/src/tui/`, feature-gated behind `tui`) Elm architecture: `app.rs` = Model, `screens/` = View, `handler.rs` = Update. @@ -71,6 +91,26 @@ Elm architecture: `app.rs` = Model, `screens/` = View, `handler.rs` = Update. - **Channel-based TUI updates:** Backend operations send `BackendMessage` through `mpsc::UnboundedSender`, processed by the TUI event loop - **Arrow-only navigation:** All directional movement uses arrow keys only (`←` `↑` `↓` `→`). No vim-style `j`/`k`/`h`/`l` letter navigation. Display hints use `[←] [↑] [↓] [→] Move`. +## FinderSync extension gotchas (macOS) + +Three non-obvious traps in `macos/GitSameBadges/`. Each one silently breaks badges with no error log — the extension self-check still shows green. + +1. **Boot-volume alias paths.** macOS auto-creates `/Volumes/` as a symlink to `/`. Finder presents home-folder URLs with that prefix (`/Volumes/Manuel-SSD-4TB/Users/m/...`) and gates `requestBadgeIdentifier` on the URL matching an entry in `directoryURLs`. `Principal.updateMonitoredDirectories()` must register both the canonical and the alias-prefixed form of every watched root, otherwise the callback fires for nothing. + +2. **macOS 26.4 sandbox rendering regression.** Both `NSImage.lockFocus()` and `NSImage(size:flipped:drawingHandler:)` produce empty/invalid pixel data when called inside a sandboxed FinderSync extension on 26.4. Symptom: Finder reserves the badge slot (folder icons shift) but no glyph renders. Workaround: build badges from SF Symbols (`NSImage(systemSymbolName:)` with palette `SymbolConfiguration`). SF Symbols are pre-rendered by macOS, no per-process drawing context required. Apple's own `r.circle.fill`/`o.circle.fill`/`u.circle.fill` are what `BadgeManager.symbolBadge` uses. + +3. **Google Drive's FinderSync poisons the badge-rendering pipeline.** When `com.google.drivefs.finderhelper.findersync` is enabled, peer FinderSync extensions render no badge image even after Finder calls `setBadgeIdentifier`. Confirmed in this environment: badges only began appearing after the user disabled Google Drive in System Settings → Login Items & Extensions. Other peers (Keka, Synology, Dropbox) coexist fine. There is no code fix; document the workaround and surface it in the in-app self-check if you can. + +`scan_roots` and `show_ambient`: defaults are `["~"]` / `false`. Never re-enable `show_ambient = true` with `~` in `scan_roots` — Finder refuses to call `requestBadgeIdentifier` on extensions whose `directoryURLs` contain the home folder (separate issue from the three above). + +## Workspace folder branding (macOS) + +The host paints a custom icon onto every workspace root via `NSWorkspace.setIcon` (wrapped in `crates/git-same-core/src/macos/folder_icon.rs`) so Finder shows it in the sidebar, column, list, icon, and Get Info views. A FinderSync extension can never replicate this — it only exposes corner badges. The icon is `crates/git-same-core/assets/workspace-folder.icns`, embedded via `include_bytes!` and regenerable via `bash toolkit/icons/build-workspace-folder-icns.sh`. + +Lifecycle: painted by `core::setup::save_workspace` and `app::commands::save_workspace`, reapplied by the monitor (`monitor::run::reapply_workspace_folder_icons`) on every full scan if the `Icon\r` is missing, and stripped by `cli::commands::reset` and `app::commands::delete_workspace`. Opt out globally with `[ui] custom_folder_icon = false`. + +**Finder Sidebar snapshot caveat.** `LSSharedFileList` captures a per-item icon bitmap into `~/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.FavoriteItems.sfl3` at the moment the user drags a folder into Favorites. That snapshot is frozen — repainting the folder's `Icon\r` does **not** update the sidebar. The only refresh path is manual: right-click the stale sidebar item → Remove from Sidebar, then drag the folder back from a Finder window into Favorites. Don't waste time looking for a programmatic refresh API; the framework doesn't expose one, and the recommended workaround used by Synology / Dropbox is the same drag-and-drop. + ## Formatting `rustfmt.toml`: `max_width = 100`, `tab_spaces = 4`, edition 2021. @@ -90,7 +130,9 @@ The test file contains `use super::*;` and all `#[test]` / `#[tokio::test]` func **Do not** write inline `#[cfg(test)] mod tests { ... }` blocks — always use separate `_tests.rs` files. -**Integration tests** remain in `tests/integration_test.rs`. +**Integration tests** live in `crates/git-same-cli/tests/integration_test.rs`. They spawn the binary via `env!("CARGO_BIN_EXE_git-same")` (compile-time path), so they always run against the freshly built CLI binary at the workspace `target/`. + +**Cross-crate test helpers:** `Repo::test()` in `git-same-core` is gated on `cfg(any(test, feature = "test-utils"))`. The CLI crate enables the `test-utils` feature in its `[dev-dependencies]` so its tests can call the helper without exposing it in production builds. ## CI/CD Workflows @@ -98,12 +140,12 @@ All workflows are `workflow_dispatch` (manual trigger) in `.github/workflows/`: | Workflow | Purpose | Trigger | |----------|---------|---------| -| `S1-Test-CI.yml` | fmt, clippy, tests, release build dry-run, coverage, alias drift, workflow secret-safety, audit | Manual dispatch | -| `S2-Release-GitHub.yml` | Gated GitHub release assets for targets in `toolkit/packaging/targets.txt` (currently 4 targets) | Manual dispatch (select tag) | -| `S3-Publish-Homebrew.yml` | Publish Homebrew cask + formula-cli | Manual dispatch (select tag) | -| `S4-Publish-Crates.yml` | Publish crates.io package | Manual dispatch (select tag) | +| `S1-Test-CI.yml` | fmt, clippy, test, build dry-run, coverage, audit | Manual dispatch | +| `S2-Release-GitHub.yml` | Full CI + cross-compile 4 targets (per `toolkit/packaging/targets.txt`) + GitHub Release | Manual dispatch (select tag) | +| `S3-Publish-Homebrew.yml` | Download release tarballs and render `git-same-cli` formula + `git-same` cask templates into `zaai-com/homebrew-tap` | Manual dispatch (select tag) | +| `S4-Publish-Crates.yml` | Two-stage publish to crates.io: `git-same-core` → poll until indexed → `git-same` | Manual dispatch (select tag) | -S2 gates release asset builds on tests, coverage, alias drift, audit, and workflow secret-safety checks. +S2 runs all S1 jobs (test, coverage, audit) as gates before building release artifacts. ## Specs & Docs diff --git a/.github/workflows/S1-Test-CI.yml b/.github/workflows/S1-Test-CI.yml index a64f36e..7528141 100644 --- a/.github/workflows/S1-Test-CI.yml +++ b/.github/workflows/S1-Test-CI.yml @@ -43,10 +43,10 @@ jobs: run: cargo +${{ matrix.rust }} fmt --all -- --check - name: Clippy - run: cargo +${{ matrix.rust }} clippy --all-targets --all-features -- -D warnings + run: cargo +${{ matrix.rust }} clippy --workspace --all-targets --all-features -- -D warnings - name: Run tests - run: cargo +${{ matrix.rust }} test --all-features -- --test-threads=1 + run: cargo +${{ matrix.rust }} test --workspace --all-features -- --test-threads=1 build: name: Build (${{ matrix.target }}) @@ -81,7 +81,43 @@ jobs: prefix-key: v1-rust-no-bin - name: Build release - run: cargo +stable build --release --target ${{ matrix.target }} + run: cargo +stable build --release -p git-same --target ${{ matrix.target }} + + tauri-debug-build: + name: Tauri App Debug Build + needs: [test] + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Install Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Enable pnpm + run: corepack enable pnpm + + - name: Verify entitlements parity + run: bash toolkit/packaging/macos/check-entitlements-parity.sh + + - name: Install frontend dependencies + run: pnpm --dir crates/git-same-app/ui install --frozen-lockfile + + - name: Build frontend + run: pnpm --dir crates/git-same-app/ui build + + - name: Build Tauri app binary + run: | + cd crates/git-same-app + ui/node_modules/.bin/tauri build --debug --no-bundle coverage: name: Code Coverage @@ -114,7 +150,7 @@ jobs: run: cargo +stable tarpaulin --all-features --workspace --timeout 120 --out xml --engine llvm - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: use_oidc: true fail_ci_if_error: false @@ -161,7 +197,7 @@ jobs: } in_bin && /^[[:space:]]*required-features[[:space:]]*=/ { has_req=1 } END { flush(); print default_count, default_name } - ' Cargo.toml)" + ' crates/git-same-cli/Cargo.toml)" if [ "${DEFAULT_COUNT:-0}" -ne 1 ]; then echo "ERROR: Cargo.toml has ${DEFAULT_COUNT:-0} default [[bin]] entries, expected 1"; exit 1 fi diff --git a/.github/workflows/S2-Release-GitHub.yml b/.github/workflows/S2-Release-GitHub.yml index 53cb198..6ad7bf5 100644 --- a/.github/workflows/S2-Release-GitHub.yml +++ b/.github/workflows/S2-Release-GitHub.yml @@ -40,10 +40,10 @@ jobs: run: cargo +${{ matrix.rust }} fmt --all -- --check - name: Clippy - run: cargo +${{ matrix.rust }} clippy --all-targets --all-features -- -D warnings + run: cargo +${{ matrix.rust }} clippy --workspace --all-targets --all-features -- -D warnings - name: Run tests - run: cargo +${{ matrix.rust }} test --all-features -- --test-threads=1 + run: cargo +${{ matrix.rust }} test --workspace --all-features -- --test-threads=1 coverage: name: Code Coverage @@ -76,7 +76,7 @@ jobs: run: cargo +stable tarpaulin --all-features --workspace --timeout 120 --out xml --engine llvm - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: use_oidc: true fail_ci_if_error: false @@ -115,7 +115,7 @@ jobs: } in_bin && /^[[:space:]]*required-features[[:space:]]*=/ { has_req=1 } END { flush(); print default_count, default_name } - ' Cargo.toml)" + ' crates/git-same-cli/Cargo.toml)" if [ "${DEFAULT_COUNT:-0}" -ne 1 ]; then echo "ERROR: Cargo.toml has ${DEFAULT_COUNT:-0} default [[bin]] entries, expected 1"; exit 1 fi @@ -267,7 +267,7 @@ jobs: prefix-key: v1-rust-no-bin - name: Build - run: cargo +stable build --release --target ${{ matrix.target }} + run: cargo +stable build --release -p git-same --target ${{ matrix.target }} - name: Resolve version from tag if: startsWith(github.ref, 'refs/tags/') @@ -388,7 +388,7 @@ jobs: find artifacts -type f -exec cp {} release-assets/ \; - name: Create/update release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v2.6.2 with: files: release-assets/* env: diff --git a/.github/workflows/S3-Publish-Homebrew.yml b/.github/workflows/S3-Publish-Homebrew.yml index ff192c7..72302c2 100644 --- a/.github/workflows/S3-Publish-Homebrew.yml +++ b/.github/workflows/S3-Publish-Homebrew.yml @@ -54,6 +54,8 @@ jobs: --pattern "git-same-${VERSION}-aarch64-unknown-linux-gnu.tar.gz" \ --pattern "git-same-${VERSION}-x86_64-apple-darwin.tar.gz" \ --pattern "git-same-${VERSION}-aarch64-apple-darwin.tar.gz" \ + --pattern "git-same-${VERSION}-x86_64.dmg" \ + --pattern "git-same-${VERSION}-aarch64.dmg" \ --dir assets - name: Compute SHA256 hashes @@ -64,10 +66,14 @@ jobs: LINUX_ARM="assets/git-same-${VERSION}-aarch64-unknown-linux-gnu.tar.gz" MAC_X86="assets/git-same-${VERSION}-x86_64-apple-darwin.tar.gz" MAC_ARM="assets/git-same-${VERSION}-aarch64-apple-darwin.tar.gz" + CASK_MAC_X86="assets/git-same-${VERSION}-x86_64.dmg" + CASK_MAC_ARM="assets/git-same-${VERSION}-aarch64.dmg" echo "linux_x86_64=$(shasum -a 256 "$LINUX_X86" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" echo "linux_aarch64=$(shasum -a 256 "$LINUX_ARM" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" echo "macos_x86_64=$(shasum -a 256 "$MAC_X86" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" echo "macos_aarch64=$(shasum -a 256 "$MAC_ARM" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + echo "cask_macos_x86_64=$(shasum -a 256 "$CASK_MAC_X86" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + echo "cask_macos_aarch64=$(shasum -a 256 "$CASK_MAC_ARM" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - name: Render formula-cli run: | @@ -86,8 +92,8 @@ jobs: run: | bash toolkit/homebrew/render-cask.sh \ "${{ steps.version.outputs.version }}" \ - --sha-arm "${{ steps.sha.outputs.macos_aarch64 }}" \ - --sha-intel "${{ steps.sha.outputs.macos_x86_64 }}" \ + --sha-arm "${{ steps.sha.outputs.cask_macos_aarch64 }}" \ + --sha-intel "${{ steps.sha.outputs.cask_macos_x86_64 }}" \ --out cask.rb - name: Verify rendered cask + formula against a temp tap diff --git a/.github/workflows/S4-Publish-Crates.yml b/.github/workflows/S4-Publish-Crates.yml index ee76e36..3f86862 100644 --- a/.github/workflows/S4-Publish-Crates.yml +++ b/.github/workflows/S4-Publish-Crates.yml @@ -23,7 +23,28 @@ jobs: - uses: Swatinem/rust-cache@v2 - - name: Publish - run: cargo +stable publish + - name: Publish git-same-core + run: cargo +stable publish -p git-same-core + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + - name: Wait for crates.io to index git-same-core + run: | + set -euo pipefail + VERSION=$(awk -F'"' '/^version *=/ {print $2; exit}' Cargo.toml) + echo "Waiting for git-same-core ${VERSION} to be indexed on crates.io..." + for i in $(seq 1 30); do + if cargo search git-same-core --limit 1 \ + | grep -q "git-same-core[[:space:]]*=[[:space:]]*\"${VERSION}\""; then + echo "Indexed." + exit 0 + fi + sleep 10 + done + echo "Timed out waiting for crates.io to index git-same-core ${VERSION}" >&2 + exit 1 + + - name: Publish git-same + run: cargo +stable publish -p git-same env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/S5-Build-MacOS-App.yml b/.github/workflows/S5-Build-MacOS-App.yml new file mode 100644 index 0000000..aecdee7 --- /dev/null +++ b/.github/workflows/S5-Build-MacOS-App.yml @@ -0,0 +1,135 @@ +name: S5 - Build macOS App + +on: + workflow_dispatch: + inputs: + version: + description: "Release version (e.g., 3.1.0)" + required: true + type: string + include_finder_extension: + description: "Embed the FinderSync extension" + required: true + type: boolean + default: true + push: + tags: + - "*.*.*" + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +permissions: + contents: write + +jobs: + build: + name: Build App (${{ matrix.arch }}) + runs-on: macos-14 + strategy: + fail-fast: false + matrix: + include: + - arch: aarch64 + target: aarch64-apple-darwin + - arch: x86_64 + target: x86_64-apple-darwin + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Resolve version + id: version + shell: bash + run: | + VERSION="${{ inputs.version }}" + if [ -z "$VERSION" ]; then + VERSION="${GITHUB_REF#refs/tags/}" + fi + if ! [[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Version must be strict semver only. Got: $VERSION" >&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + + - name: Install Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Enable pnpm + run: corepack enable pnpm + + - name: Verify entitlements parity + run: bash toolkit/packaging/macos/check-entitlements-parity.sh + + - name: Build signed app DMG + env: + VERSION: ${{ steps.version.outputs.version }} + ARCH: ${{ matrix.arch }} + WORKSPACE_ROOT: ${{ github.workspace }} + OUTPUT_DIR: ${{ github.workspace }}/dist/macos + # Default-on: tag pushes (where `inputs.include_finder_extension` is + # null) ship the full bundle. Only an explicit `false` from + # workflow_dispatch suppresses the appex. + INCLUDE_FINDER_EXTENSION: ${{ inputs.include_finder_extension == false && '0' || '1' }} + APPLE_DEVELOPER_CERTIFICATE_P12: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12 }} + APPLE_DEVELOPER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ vars.APPLE_ID }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + run: bash toolkit/packaging/macos/build-app-bundle.sh + + - name: Upload app artifacts + uses: actions/upload-artifact@v7 + with: + name: git-same-${{ steps.version.outputs.version }}-${{ matrix.arch }}.dmg + path: | + dist/macos/git-same-${{ steps.version.outputs.version }}-${{ matrix.arch }}.dmg + dist/macos/git-same-${{ steps.version.outputs.version }}-${{ matrix.arch }}.dmg.sha256 + + release: + name: Attach App DMGs to GitHub Release + needs: [build] + runs-on: ubuntu-latest + steps: + - name: Resolve version + id: version + shell: bash + run: | + VERSION="${{ inputs.version }}" + if [ -z "$VERSION" ]; then + VERSION="${GITHUB_REF#refs/tags/}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + path: artifacts + + - name: Collect release assets + run: | + mkdir -p release-assets + find artifacts -type f -exec cp {} release-assets/ \; + + - name: Create/update release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ steps.version.outputs.version }} + files: release-assets/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index a08ad33..969aa64 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,31 @@ **/*.rs.bk *.pdb +# Frontend +node_modules/ +dist/ +crates/git-same-app/gen/ +.pnpm-store/ + +# Tauri local cache & bundle outputs outside of /target/ +.tauri/ +crates/git-same-app/ui/.svelte-kit/ +crates/git-same-app/ui/.vite/ +*.dmg +*.app.zip + +# Tauri icon CLI output for non-macOS bundle targets we do not ship. +# Regenerable from icons/1024x1024.png via `pnpm tauri icon`. +crates/git-same-app/icons/64x64.png +crates/git-same-app/icons/icon.png +crates/git-same-app/icons/Square*Logo.png +crates/git-same-app/icons/StoreLogo.png +crates/git-same-app/icons/android/ +crates/git-same-app/icons/ios/ + +# Concept variants generated by toolkit/icons/generate-icons.swift for preview. +crates/git-same-app/icons/variants/ + # IDE .vscode/ .idea/ @@ -35,3 +60,9 @@ cobertura.xml # Claude Code .claude/settings.local.json + +# Xcode (macOS FinderSync) +macos/*.xcodeproj/xcuserdata/ +macos/*.xcodeproj/project.xcworkspace/xcuserdata/ +macos/build/ +macos/DerivedData/ diff --git a/Cargo.lock b/Cargo.lock index 4550ce4..e5718f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +17,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -82,6 +103,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -100,7 +130,30 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", ] [[package]] @@ -120,15 +173,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -136,9 +189,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -146,6 +199,12 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -158,7 +217,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -167,6 +235,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -175,9 +249,12 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -188,11 +265,56 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" @@ -200,11 +322,87 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] [[package]] name = "castaway" @@ -217,9 +415,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "jobserver", @@ -227,6 +425,33 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -241,16 +466,16 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -290,10 +515,10 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -348,9 +573,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -381,6 +606,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -407,6 +642,30 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -416,13 +675,43 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crossterm" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "crossterm_winapi", "derive_more", "document-features", @@ -460,9 +749,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" dependencies = [ "lab", - "phf", + "phf 0.11.3", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.118", ] +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "darling" version = "0.23.0" @@ -483,7 +811,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -494,7 +822,18 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.118", +] + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", ] [[package]] @@ -509,7 +848,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", + "serde_core", ] [[package]] @@ -531,7 +870,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -574,15 +913,50 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -595,22 +969,102 @@ dependencies = [ ] [[package]] -name = "dunce" -version = "1.0.5" +name = "dom_query" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set 0.8.0", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] [[package]] -name = "either" -version = "1.15.0" +name = "dpi" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] [[package]] -name = "encode_unicode" -version = "1.0.0" +name = "dtoa" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "encoding_rs" @@ -627,6 +1081,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -652,16 +1117,41 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -691,6 +1181,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -699,15 +1199,36 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] -name = "foldhash" -version = "0.2.0" +name = "foreign-types" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] name = "form_urlencoded" @@ -724,6 +1245,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.32" @@ -780,7 +1310,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -812,6 +1342,105 @@ dependencies = [ "slab", ] +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -851,34 +1480,106 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", ] [[package]] name = "git-same" -version = "3.0.2" +version = "3.1.0" dependencies = [ "anyhow", - "async-trait", "chrono", "clap", "clap_complete", "clap_mangen", "console", "crossterm", + "git-same-core", + "indicatif", + "ratatui", + "serde", + "serde_json", + "shellexpand", + "tempfile", + "thiserror 2.0.18", + "tokio", + "toml 1.1.2+spec-1.1.0", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "git-same-app" +version = "3.1.0" +dependencies = [ + "anyhow", + "chrono", + "git-same-core", + "notify", + "serde", + "serde_json", + "shellexpand", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tokio", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "git-same-core" +version = "3.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "console", "directories", "futures", "indicatif", "mockito", - "ratatui", + "notify", + "objc2", + "objc2-app-kit", + "objc2-foundation", "reqwest", "serde", "serde_json", @@ -887,86 +1588,220 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-test", - "toml", + "toml 1.1.2+spec-1.1.0", "tracing", "tracing-subscriber", ] [[package]] -name = "h2" -version = "0.4.14" +name = "glib" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "atomic-waker", - "bytes", - "fnv", + "bitflags 2.13.0", + "futures-channel", "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", ] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "glib-macros" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" dependencies = [ - "foldhash 0.1.5", + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] -name = "hashbrown" -version = "0.16.1" +name = "glib-sys" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", + "libc", + "system-deps", ] [[package]] -name = "hashbrown" -version = "0.17.1" +name = "glob" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] -name = "heck" -version = "0.5.0" +name = "gobject-sys" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] [[package]] -name = "hex" -version = "0.4.3" +name = "gtk" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] [[package]] -name = "http" -version = "1.4.0" +name = "gtk-sys" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" dependencies = [ - "bytes", - "itoa", + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", ] [[package]] -name = "http-body" -version = "1.0.1" +name = "gtk3-macros" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" dependencies = [ - "bytes", - "http", + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", ] [[package]] @@ -996,9 +1831,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1037,7 +1872,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1068,7 +1903,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1080,6 +1915,16 @@ dependencies = [ "cc", ] +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1162,12 +2007,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -1195,6 +2034,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1229,6 +2079,35 @@ dependencies = [ "rustversion", ] +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inotify" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" +dependencies = [ + "bitflags 2.13.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "instability" version = "0.3.12" @@ -1239,7 +2118,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1269,6 +2148,45 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni" version = "0.22.4" @@ -1278,12 +2196,12 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys", + "jni-sys 0.4.1", "log", "simd_cesu8", "thiserror 2.0.18", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1296,7 +2214,16 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.118", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", ] [[package]] @@ -1315,7 +2242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1330,16 +2257,37 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "kasuari" version = "0.4.12" @@ -1351,6 +2299,37 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + [[package]] name = "lab" version = "0.11.0" @@ -1364,10 +2343,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] [[package]] name = "libc" @@ -1375,11 +2372,36 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -1390,7 +2412,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -1422,17 +2444,17 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lru" -version = "0.16.4" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -1451,6 +2473,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1462,9 +2495,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memmem" @@ -1493,11 +2526,21 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -1531,42 +2574,120 @@ dependencies = [ ] [[package]] -name = "nix" -version = "0.29.0" +name = "muda" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", ] [[package]] -name = "nom" -version = "7.1.3" +name = "ndk" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "memchr", - "minimal-lexical", + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", ] [[package]] -name = "nu-ansi-term" -version = "0.50.3" +name = "ndk-sys" +version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "windows-sys 0.61.2", + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", ] [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -1576,7 +2697,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1588,6 +2709,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -1597,6 +2740,224 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1630,6 +2991,55 @@ dependencies = [ "num-traits", ] +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1650,7 +3060,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1689,7 +3099,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1708,8 +3118,19 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", ] [[package]] @@ -1718,8 +3139,18 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", ] [[package]] @@ -1728,21 +3159,44 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.6", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1754,12 +3208,66 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1791,13 +3299,62 @@ dependencies = [ ] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "precomputed-hash" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", "proc-macro2", - "syn 2.0.117", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", ] [[package]] @@ -1809,6 +3366,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1932,31 +3498,35 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.30.0" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", "ratatui-macros", + "ratatui-termina", "ratatui-termwiz", "ratatui-widgets", + "serde", ] [[package]] name = "ratatui-core" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "compact_str", - "hashbrown 0.16.1", - "indoc", + "critical-section", + "hashbrown 0.17.1", "itertools", "kasuari", "lru", + "palette", + "serde", "strum", "thiserror 2.0.18", "unicode-segmentation", @@ -1966,9 +3536,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if", "crossterm", @@ -1978,19 +3548,30 @@ dependencies = [ [[package]] name = "ratatui-macros" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" dependencies = [ "ratatui-core", "ratatui-widgets", ] [[package]] -name = "ratatui-termwiz" +name = "ratatui-termina" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" dependencies = [ "ratatui-core", "termwiz", @@ -1998,30 +3579,37 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.16.1", + "bitflags 2.13.0", + "hashbrown 0.17.1", "indoc", "instability", "itertools", "line-clipping", "ratatui-core", + "serde", "strum", "time", "unicode-segmentation", "unicode-width", ] +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -2035,11 +3623,31 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -2060,20 +3668,21 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -2095,15 +3704,41 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -2145,7 +3780,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -2168,9 +3803,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -2196,7 +3831,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls", @@ -2257,6 +3892,57 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2269,7 +3955,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -2286,11 +3972,34 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.0", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -2302,6 +4011,18 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -2319,14 +4040,25 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -2335,6 +4067,26 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -2356,6 +4108,69 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2387,9 +4202,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -2422,6 +4237,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -2458,126 +4279,550 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.0", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2616f96cb644bf2c5c456d9de4d5d5100e592d7424c74d8b55c5cb96e359e93" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni 0.21.1", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] [[package]] -name = "socket2" -version = "0.6.3" +name = "tauri-build" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ - "libc", - "windows-sys 0.61.2", + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", ] [[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "strsim" -version = "0.11.1" +name = "tauri-codegen" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ - "strum_macros", + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.118", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", ] [[package]] -name = "strum_macros" -version = "0.27.2" +name = "tauri-macros" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", + "tauri-codegen", + "tauri-utils", ] [[package]] -name = "subtle" -version = "2.6.1" +name = "tauri-plugin" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] [[package]] -name = "syn" -version = "1.0.109" +name = "tauri-plugin-dialog" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", ] [[package]] -name = "syn" -version = "2.0.117" +name = "tauri-plugin-fs" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", ] [[package]] -name = "sync_wrapper" -version = "1.0.2" +name = "tauri-runtime" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ - "futures-core", + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", ] [[package]] -name = "synstructure" -version = "0.13.2" +name = "tauri-runtime-wry" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "gtk", + "http", + "jni 0.21.1", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", ] [[package]] -name = "system-configuration" -version = "0.7.0" +name = "tauri-utils" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.9.4", - "system-configuration-sys", + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf 0.13.1", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", ] [[package]] -name = "system-configuration-sys" -version = "0.6.0" +name = "tauri-winres" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ - "core-foundation-sys", - "libc", + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -2587,12 +4832,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.0", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -2601,8 +4869,8 @@ checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", "nom", - "phf", - "phf_codegen", + "phf 0.11.3", + "phf_codegen 0.11.3", ] [[package]] @@ -2621,8 +4889,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", - "bitflags 2.11.1", + "base64 0.22.1", + "bitflags 2.13.0", "fancy-regex", "filedescriptor", "finl_unicode", @@ -2638,7 +4906,7 @@ dependencies = [ "ordered-float", "pest", "pest_derive", - "phf", + "phf 0.11.3", "sha2", "signal-hook", "siphasher", @@ -2682,7 +4950,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2693,7 +4961,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2707,9 +4975,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" dependencies = [ "deranged", "libc", @@ -2718,13 +4986,24 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] [[package]] name = "tinystr" @@ -2776,7 +5055,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2824,19 +5103,64 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", ] [[package]] @@ -2848,13 +5172,49 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] [[package]] @@ -2880,11 +5240,11 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "futures-util", "http", @@ -2927,7 +5287,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2969,17 +5329,45 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tray-icon" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -2987,6 +5375,47 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2995,9 +5424,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" @@ -3016,12 +5445,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "unit-prefix" version = "0.5.2" @@ -3044,8 +5467,27 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3060,13 +5502,14 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "atomic", - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -3074,7 +5517,13 @@ dependencies = [ name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" [[package]] name = "version_check" @@ -3082,6 +5531,26 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "vtparse" version = "0.6.2" @@ -3118,27 +5587,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -3149,9 +5609,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ "js-sys", "wasm-bindgen", @@ -3159,9 +5619,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3169,89 +5629,160 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] [[package]] -name = "wasm-encoder" -version = "0.244.0" +name = "wasm-streams" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ - "leb128fmt", - "wasmparser", + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] -name = "wasm-metadata" -version = "0.244.0" +name = "web-sys" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", ] [[package]] -name = "wasmparser" -version = "0.244.0" +name = "web_atoms" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap", - "semver", + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache", + "string_cache_codegen", ] [[package]] -name = "web-sys" -version = "0.3.98" +name = "webkit2gtk" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" dependencies = [ - "js-sys", - "wasm-bindgen", + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", ] [[package]] -name = "web-time" -version = "1.1.0" +name = "webkit2gtk-sys" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" dependencies = [ - "js-sys", - "wasm-bindgen", + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", ] [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -3355,6 +5886,56 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3363,9 +5944,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -3376,7 +5968,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3387,24 +5979,49 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-registry" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -3413,7 +6030,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -3422,7 +6048,16 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", ] [[package]] @@ -3434,6 +6069,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -3449,7 +6093,22 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", ] [[package]] @@ -3474,7 +6133,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -3485,6 +6144,30 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3497,6 +6180,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3509,6 +6198,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3533,6 +6228,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3545,6 +6246,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3557,6 +6264,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3569,6 +6282,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3583,115 +6302,120 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" +version = "0.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" dependencies = [ - "wit-bindgen-rust-macro", + "memchr", ] [[package]] -name = "wit-bindgen" -version = "0.57.1" +name = "winnow" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] -name = "wit-bindgen-core" -version = "0.51.0" +name = "winnow" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ - "anyhow", - "heck", - "wit-parser", + "memchr", ] [[package]] -name = "wit-bindgen-rust" -version = "0.51.0" +name = "winreg" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", + "cfg-if", + "windows-sys 0.59.0", ] [[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] -name = "wit-component" -version = "0.244.0" +name = "wry" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", ] [[package]] -name = "wit-parser" -version = "0.244.0" +name = "x11" +version = "2.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", + "libc", + "pkg-config", ] [[package]] -name = "writeable" -version = "0.6.3" +name = "x11-dl" +version = "2.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3706,28 +6430,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3747,15 +6471,15 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" @@ -3787,7 +6511,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d54e215..384aab8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,94 +1,44 @@ -[package] -name = "git-same" -version = "3.0.2" +[workspace] +members = [ + "crates/git-same-core", + "crates/git-same-cli", + "crates/git-same-app", +] +resolver = "2" + +[workspace.package] +version = "3.1.0" edition = "2021" -authors = ["Git-Same Contributors"] -description = "Mirror GitHub structure /orgs/repos/ to local file system." +authors = ["Manuel Gruber"] license = "MIT" repository = "https://github.com/zaai-com/git-same" -keywords = ["git", "github", "cli", "clone", "sync"] -categories = ["command-line-utilities", "development-tools"] -# Aliases (gitsame, gitsa, gisa) are created as symlinks by installers. -# See toolkit/packaging/binary-aliases.txt for the full list. -[[bin]] -name = "git-same" -path = "src/main.rs" - -# Release-only helpers. Gated behind the `release-tools` feature so they don't -# bloat normal `cargo build` and don't pull in clap_complete/clap_mangen for -# end-user installs. Drives the completions + manpage shipped in release tarballs. -[[bin]] -name = "gen-completions" -path = "src/bin/gen_completions.rs" -required-features = ["release-tools"] - -[[bin]] -name = "gen-manpage" -path = "src/bin/gen_manpage.rs" -required-features = ["release-tools"] - -[features] -default = ["tui"] -tui = ["dep:ratatui", "dep:crossterm"] -release-tools = ["dep:clap_complete", "dep:clap_mangen"] - -[dependencies] -# CLI parsing +[workspace.dependencies] clap = { version = "4", features = ["derive"] } - - -# Async runtime tokio = { version = "1", features = ["full"] } - -# HTTP client for GitHub API reqwest = { version = "0.13", features = ["json"] } - -# JSON/TOML serialization serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "1" - -# Progress bars and terminal output indicatif = "0.18" console = "0.16" - -# XDG directories (~/.config/git-same) directories = "6" - -# Error handling thiserror = "2" anyhow = "1" - -# Shell expansion (~/ paths) shellexpand = "3" - -# Async trait support async-trait = "0.1" - -# Date/time handling chrono = { version = "0.4", features = ["serde"] } - -# Futures utilities futures = "0.3" - -# Structured logging tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -# TUI (optional, behind "tui" feature) -ratatui = { version = "0.30", optional = true } -crossterm = { version = "0.29", optional = true } - -# Release tooling (optional, behind "release-tools" feature) -clap_complete = { version = "4", optional = true } -clap_mangen = { version = "0.3", optional = true } - -[dev-dependencies] -# Testing +ratatui = "0.30" +crossterm = "0.29" +clap_complete = "4" +clap_mangen = "0.3" tokio-test = "0.4" mockito = "1" tempfile = "3" +notify = "8" [profile.release] strip = true diff --git a/conductor.json b/conductor.json index 6fd997f..f1fa5fd 100644 --- a/conductor.json +++ b/conductor.json @@ -6,6 +6,7 @@ "run": "./toolkit/conductor/run.sh", "archive": "./toolkit/conductor/archive.sh" }, + "runScriptMode": "nonconcurrent", "stack": { "language": "Rust", "cli": "Clap v4", diff --git a/crates/git-same-app/Cargo.toml b/crates/git-same-app/Cargo.toml new file mode 100644 index 0000000..53478ca --- /dev/null +++ b/crates/git-same-app/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "git-same-app" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Desktop app for git-same." +publish = false + +[[bin]] +name = "git-same-app" +path = "src/main.rs" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +git-same-core = { path = "../git-same-core" } +anyhow = { workspace = true } +chrono = { workspace = true } +notify = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +shellexpand = { workspace = true } +tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" +tokio = { workspace = true } +toml = { workspace = true } diff --git a/crates/git-same-app/build.rs b/crates/git-same-app/build.rs new file mode 100644 index 0000000..261851f --- /dev/null +++ b/crates/git-same-app/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build(); +} diff --git a/crates/git-same-app/capabilities/default.json b/crates/git-same-app/capabilities/default.json new file mode 100644 index 0000000..7918f74 --- /dev/null +++ b/crates/git-same-app/capabilities/default.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default capability for the main Git-Same window: core IPC plus event listen/emit for the status-updated stream.", + "windows": ["main"], + "permissions": [ + "core:default", + "core:event:default", + "dialog:allow-open" + ] +} diff --git a/crates/git-same-app/entitlements.plist b/crates/git-same-app/entitlements.plist new file mode 100644 index 0000000..e26b832 --- /dev/null +++ b/crates/git-same-app/entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.57KL6Y7V32.com.zaai.git-same + + + diff --git a/crates/git-same-app/icons/1024x1024.png b/crates/git-same-app/icons/1024x1024.png new file mode 100644 index 0000000..748a0b1 Binary files /dev/null and b/crates/git-same-app/icons/1024x1024.png differ diff --git a/crates/git-same-app/icons/128x128.png b/crates/git-same-app/icons/128x128.png new file mode 100644 index 0000000..154d4a3 Binary files /dev/null and b/crates/git-same-app/icons/128x128.png differ diff --git a/crates/git-same-app/icons/128x128@2x.png b/crates/git-same-app/icons/128x128@2x.png new file mode 100644 index 0000000..d8a72ca Binary files /dev/null and b/crates/git-same-app/icons/128x128@2x.png differ diff --git a/crates/git-same-app/icons/32x32.png b/crates/git-same-app/icons/32x32.png new file mode 100644 index 0000000..1302d93 Binary files /dev/null and b/crates/git-same-app/icons/32x32.png differ diff --git a/crates/git-same-app/icons/icon.icns b/crates/git-same-app/icons/icon.icns new file mode 100644 index 0000000..766c1c7 Binary files /dev/null and b/crates/git-same-app/icons/icon.icns differ diff --git a/crates/git-same-app/icons/icon.ico b/crates/git-same-app/icons/icon.ico new file mode 100644 index 0000000..f5de026 Binary files /dev/null and b/crates/git-same-app/icons/icon.ico differ diff --git a/crates/git-same-app/src/commands.rs b/crates/git-same-app/src/commands.rs new file mode 100644 index 0000000..b298ba0 --- /dev/null +++ b/crates/git-same-app/src/commands.rs @@ -0,0 +1,1392 @@ +use git_same_core::auth::get_auth_for_provider; +use git_same_core::cache::{CacheManager, DiscoveryCache}; +use git_same_core::checks::CheckResult; +use git_same_core::config::workspace::tilde_collapse_path; +use git_same_core::config::{ + Config, ConfigCloneOptions, FilterOptions, SyncMode, WorkspaceConfig, WorkspaceManager, + WorkspaceProvider, +}; +use git_same_core::discovery::DiscoveryOrchestrator; +use git_same_core::domain::RepoPathTemplate; +use git_same_core::errors::AppError; +use git_same_core::ipc::{IpcConfig, StatusFileWriter}; +use git_same_core::macos::folder_icon; +use git_same_core::progress::{ProgressEvent, ProgressReporter}; +use git_same_core::provider::{create_provider, NoProgress}; +use git_same_core::setup::{authenticate_provider, discover_org_entries}; +use git_same_core::types::{FinderStatus, OwnedRepo, ProviderKind}; +use git_same_core::workflows::sync_workspace::{ + execute_prepared_sync, prepare_sync_workspace, SyncWorkspaceRequest, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::str::FromStr; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; +use tauri::Emitter; + +const DAEMON_STALE_AFTER_SECS: u64 = 90; +const FINDER_EXTENSION_ID: &str = "com.zaai.git-same.badges"; +const MONITOR_LAUNCH_AGENT_LABEL: &str = "com.zaai.git-same.monitor"; +const MONITOR_LAUNCH_AGENT_FILE: &str = "com.zaai.git-same.monitor.plist"; +const MONITOR_PLIST_TEMPLATE: &str = include_str!("../../../macos/com.zaai.git-same.monitor.plist"); + +#[cfg(test)] +#[path = "commands_tests.rs"] +mod tests; + +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceSummary { + pub id: String, + pub name: String, + pub root: String, + pub provider: String, + pub org_count: usize, + pub last_sync: Option, + pub default: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CloneOptionsDto { + pub depth: u32, + pub branch: String, + pub recurse_submodules: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FilterOptionsDto { + pub include_archived: bool, + pub include_forks: bool, + pub orgs: Vec, + pub exclude_repos: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinderConfigDto { + pub scan_roots: Vec, + pub max_depth: usize, + pub exclude_dirs: Vec, + pub show_ambient: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MonitorConfigDto { + pub fullscan_interval_secs: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppConfigDto { + pub config_path: String, + pub exists: bool, + pub structure: String, + pub concurrency: usize, + pub sync_mode: String, + pub default_workspace: Option, + pub refresh_interval: u64, + pub clone: CloneOptionsDto, + pub filters: FilterOptionsDto, + pub workspaces: Vec, + pub finder: FinderConfigDto, + pub monitor: MonitorConfigDto, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppConfigInput { + pub structure: String, + pub concurrency: usize, + pub sync_mode: String, + pub default_workspace: Option, + pub refresh_interval: u64, + pub clone: CloneOptionsDto, + pub filters: FilterOptionsDto, + pub workspaces: Vec, + pub finder: FinderConfigDto, + pub monitor: MonitorConfigDto, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceProviderDto { + pub kind: String, + pub label: String, + pub api_url: Option, + pub prefer_ssh: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceDetailDto { + pub id: String, + pub name: String, + pub root: String, + pub config_path: String, + pub provider: WorkspaceProviderDto, + pub username: String, + pub orgs: Vec, + pub include_repos: Vec, + pub exclude_repos: Vec, + pub structure: Option, + pub sync_mode: Option, + pub clone_options: Option, + pub filters: FilterOptionsDto, + pub concurrency: Option, + pub refresh_interval: Option, + pub last_synced: Option, + pub default: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceInput { + pub id: Option, + pub root: String, + pub provider: WorkspaceProviderDto, + pub username: String, + pub orgs: Vec, + pub include_repos: Vec, + pub exclude_repos: Vec, + pub structure: Option, + pub sync_mode: Option, + pub clone_options: Option, + pub filters: FilterOptionsDto, + pub concurrency: Option, + pub refresh_interval: Option, + pub default: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RequirementCheckDto { + pub name: String, + pub passed: bool, + pub message: String, + pub suggestion: Option, + pub critical: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProviderDiscoveryDto { + pub username: Option, + pub orgs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProviderOrgDto { + pub name: String, + pub repo_count: usize, + pub selected: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WorkspaceStructureDto { + pub workspace_id: String, + pub name: String, + pub root: String, + pub provider: String, + pub host: String, + pub source: String, + pub cache_age_secs: Option, + pub error: Option, + pub repos: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WorkspaceStructureRepoDto { + pub owner: String, + pub name: String, + pub full_name: String, + pub url: String, + pub local_path: String, + pub local_exists: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct StatusSnapshot { + pub status_path: String, + pub updated_at: Option, + pub stale: bool, + pub status: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ExtensionStatus { + pub installed: bool, + pub enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MonitorLaunchAgentStatusDto { + pub label: String, + pub plist_path: String, + pub binary_path: Option, + pub installed: bool, + pub loaded: bool, + pub running: bool, + pub state: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SyncProgressPayload { + pub workspace_id: String, + pub event: ProgressEvent, +} + +#[tauri::command] +pub async fn list_workspaces() -> Result, String> { + workspace_summaries().map_err(error_string) +} + +#[tauri::command] +pub fn read_app_config() -> Result { + let path = Config::default_path().map_err(error_string)?; + let exists = path.exists(); + let config = Config::load_from(&path).map_err(error_string)?; + Ok(app_config_dto(&config, &path, exists)) +} + +#[tauri::command] +pub fn ensure_config() -> Result { + let path = ensure_config_file().map_err(error_string)?; + let config = Config::load_from(&path).map_err(error_string)?; + Ok(app_config_dto(&config, &path, true)) +} + +#[tauri::command] +pub fn save_app_config(input: AppConfigInput) -> Result { + let path = ensure_config_file().map_err(error_string)?; + let config = app_config_input(input).map_err(error_string)?; + let content = toml::to_string_pretty(&config) + .map_err(|error| format!("Failed to serialize config: {error}"))?; + fs::write(&path, content) + .map_err(|error| format!("Failed to write config at '{}': {error}", path.display()))?; + Ok(app_config_dto(&config, &path, true)) +} + +#[tauri::command] +pub fn read_workspace(workspace_id: String) -> Result { + let config = Config::load().map_err(error_string)?; + let workspace = + WorkspaceManager::resolve(Some(&workspace_id), &config).map_err(error_string)?; + Ok(workspace_detail(&workspace, &config)) +} + +#[tauri::command] +pub fn save_workspace(input: WorkspaceInput) -> Result { + ensure_config_file().map_err(error_string)?; + let config = Config::load().map_err(error_string)?; + let previous = input + .id + .as_deref() + .and_then(|id| WorkspaceManager::resolve(Some(id), &config).ok()); + let was_default = previous + .as_ref() + .map(|workspace| workspace_is_default(workspace, config.default_workspace.as_deref())) + .unwrap_or(false); + let root = prepare_workspace_root(&input.root).map_err(error_string)?; + let mut workspace = WorkspaceConfig::new_from_root(&root); + + workspace.provider = provider_input(&input.provider).map_err(error_string)?; + workspace.username = input.username; + workspace.orgs = clean_string_list(input.orgs); + workspace.include_repos = clean_string_list(input.include_repos); + workspace.exclude_repos = clean_string_list(input.exclude_repos); + workspace.structure = clean_optional(input.structure); + workspace.sync_mode = match clean_optional(input.sync_mode) { + Some(sync_mode) => Some(SyncMode::from_str(&sync_mode).map_err(error_string)?), + None => None, + }; + workspace.clone_options = input.clone_options.map(clone_options_input); + workspace.filters = filter_options_input(input.filters); + workspace.concurrency = input.concurrency; + workspace.refresh_interval = input.refresh_interval; + workspace.last_synced = previous + .as_ref() + .and_then(|workspace| workspace.last_synced.clone()); + + WorkspaceManager::save(&workspace).map_err(error_string)?; + + if let Some(previous) = previous { + if !same_path(&previous.root_path, &workspace.root_path) { + folder_icon::clear_or_log(&previous.root_path); + WorkspaceManager::delete(&previous.root_path).map_err(error_string)?; + } + } + + if config.ui.custom_folder_icon { + folder_icon::set_or_log(&workspace.root_path, folder_icon::WORKSPACE_FOLDER_ICNS); + } + + let collapsed = tilde_collapse_path(&workspace.root_path); + if input.default { + Config::save_default_workspace(Some(&collapsed)).map_err(error_string)?; + } else if was_default { + Config::save_default_workspace(None).map_err(error_string)?; + } + + let config = Config::load().map_err(error_string)?; + Ok(workspace_detail(&workspace, &config)) +} + +#[tauri::command] +pub fn delete_workspace(workspace_id: String) -> Result, String> { + let config = Config::load().map_err(error_string)?; + let workspace = + WorkspaceManager::resolve(Some(&workspace_id), &config).map_err(error_string)?; + let was_default = workspace_is_default(&workspace, config.default_workspace.as_deref()); + + folder_icon::clear_or_log(&workspace.root_path); + WorkspaceManager::delete(&workspace.root_path).map_err(error_string)?; + if was_default { + Config::save_default_workspace(None).map_err(error_string)?; + } + + workspace_summaries().map_err(error_string) +} + +#[tauri::command] +pub fn set_default_workspace( + workspace_id: Option, +) -> Result, String> { + match workspace_id + .as_deref() + .and_then(|id| clean_optional(Some(id.to_string()))) + { + Some(id) => { + let config = Config::load().map_err(error_string)?; + let workspace = WorkspaceManager::resolve(Some(&id), &config).map_err(error_string)?; + let collapsed = tilde_collapse_path(&workspace.root_path); + Config::save_default_workspace(Some(&collapsed)).map_err(error_string)?; + } + None => Config::save_default_workspace(None).map_err(error_string)?, + } + + workspace_summaries().map_err(error_string) +} + +#[tauri::command] +pub async fn check_requirements() -> Result, String> { + let mut checks: Vec = git_same_core::checks::check_requirements() + .await + .into_iter() + .map(requirement_check_dto) + .collect(); + checks.extend(app_requirement_checks()); + Ok(checks) +} + +#[tauri::command] +pub fn monitor_launch_agent_status() -> Result { + monitor_launch_agent_status_inner().map_err(error_string) +} + +#[tauri::command] +pub fn install_monitor_launch_agent() -> Result { + install_monitor_launch_agent_inner().map_err(error_string) +} + +#[tauri::command] +pub fn restart_monitor_launch_agent() -> Result { + restart_monitor_launch_agent_inner().map_err(error_string) +} + +#[tauri::command] +pub async fn discover_provider_orgs( + provider: WorkspaceProviderDto, +) -> Result { + let provider = provider_input(&provider).map_err(error_string)?; + if provider.kind != ProviderKind::GitHub { + return Err("Only GitHub workspace discovery is currently enabled".to_string()); + } + + let auth = authenticate_provider(provider.clone()).await?; + let orgs = discover_org_entries(provider, auth.token) + .await? + .into_iter() + .map(|org| ProviderOrgDto { + name: org.name, + repo_count: org.repo_count, + selected: org.selected, + }) + .collect(); + + Ok(ProviderDiscoveryDto { + username: auth.username, + orgs, + }) +} + +#[tauri::command] +pub async fn read_workspace_structure( + workspace_id: String, +) -> Result { + read_workspace_structure_inner(workspace_id) + .await + .map_err(error_string) +} + +#[tauri::command] +pub async fn read_status() -> Result { + read_status_snapshot().map_err(error_string) +} + +#[tauri::command] +pub async fn start_sync( + app: tauri::AppHandle, + workspace_id: String, +) -> Result { + let config = Config::load().map_err(error_string)?; + let mut workspace = + WorkspaceManager::resolve(Some(&workspace_id), &config).map_err(error_string)?; + let progress = sync_progress_reporter(app, workspace_id.clone()); + + let prepared = prepare_sync_workspace( + SyncWorkspaceRequest { + config: &config, + workspace: &workspace, + refresh: false, + skip_uncommitted: true, + pull: false, + concurrency_override: None, + create_base_path: false, + }, + &progress, + ) + .await + .map_err(error_string)?; + + let outcome = execute_prepared_sync( + &prepared, + false, + Arc::new(progress.clone()), + Arc::new(progress.clone()), + ) + .await; + if outcome + .clone_summary + .as_ref() + .is_some_and(|summary| summary.failed > 0) + || outcome + .sync_summary + .as_ref() + .is_some_and(|summary| summary.failed > 0) + { + return Err("Sync completed with failures".to_string()); + } + + workspace.last_synced = Some(chrono::Utc::now().to_rfc3339()); + WorkspaceManager::save(&workspace).map_err(error_string)?; + let ipc = IpcConfig::default_path().map_err(error_string)?; + read_status_snapshot_with(&ipc).map_err(error_string) +} + +fn sync_progress_reporter(app: tauri::AppHandle, workspace_id: String) -> ProgressReporter { + ProgressReporter::new(move |event| { + let _ = app.emit( + "sync-progress", + SyncProgressPayload { + workspace_id: workspace_id.clone(), + event, + }, + ); + }) +} + +#[tauri::command] +pub fn extension_status() -> Result { + #[cfg(target_os = "macos")] + { + let output = std::process::Command::new("/usr/bin/pluginkit") + .args(["-m", "-v", "-i", FINDER_EXTENSION_ID]) + .output() + .map_err(|err| format!("pluginkit invocation failed: {err}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_pluginkit_output(&stdout, FINDER_EXTENSION_ID)) + } + #[cfg(not(target_os = "macos"))] + { + Ok(ExtensionStatus { + installed: false, + enabled: false, + }) + } +} + +#[tauri::command] +pub fn open_url(url: String) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + std::process::Command::new("/usr/bin/open") + .arg(&url) + .spawn() + .map(|_| ()) + .map_err(|err| format!("open failed: {err}")) + } + #[cfg(not(target_os = "macos"))] + { + let _ = url; + Err("open_url is only implemented on macOS".to_string()) + } +} + +fn monitor_launch_agent_status_inner() -> Result { + let plist_path = monitor_launch_agent_path()?; + let installed = plist_path.exists(); + let binary_path = monitor_binary_path().ok(); + let launchctl = launchctl_print_monitor(); + let loaded = launchctl + .as_ref() + .is_ok_and(|output| output.status.success()); + let stdout = launchctl + .as_ref() + .ok() + .map(|output| String::from_utf8_lossy(&output.stdout).to_string()) + .unwrap_or_default(); + let running = loaded && launchctl_output_has_pid(&stdout); + let state = monitor_agent_state(installed, loaded, running); + Ok(MonitorLaunchAgentStatusDto { + label: MONITOR_LAUNCH_AGENT_LABEL.to_string(), + plist_path: plist_path.display().to_string(), + binary_path: binary_path.map(|path| path.display().to_string()), + installed, + loaded, + running, + message: monitor_agent_message(&state), + state, + }) +} + +fn install_monitor_launch_agent_inner() -> Result { + let plist_path = monitor_launch_agent_path()?; + let binary_path = monitor_binary_path()?; + let rendered = render_monitor_plist(&binary_path)?; + if let Some(parent) = plist_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + AppError::path(format!( + "Failed to create LaunchAgents directory '{}': {error}", + parent.display() + )) + })?; + } + fs::write(&plist_path, rendered).map_err(|error| { + AppError::path(format!( + "Failed to write LaunchAgent '{}': {error}", + plist_path.display() + )) + })?; + restart_monitor_launch_agent_inner() +} + +fn restart_monitor_launch_agent_inner() -> Result { + let plist_path = monitor_launch_agent_path()?; + if !plist_path.exists() { + return install_monitor_launch_agent_inner(); + } + let domain = launchctl_domain(); + let service = format!("{domain}/{MONITOR_LAUNCH_AGENT_LABEL}"); + let plist_arg = plist_path.display().to_string(); + let _ = Command::new("/bin/launchctl") + .args(["bootout", &domain, &plist_arg]) + .output(); + run_launchctl(&["bootstrap", &domain, &plist_arg])?; + run_launchctl(&["kickstart", "-k", &service])?; + monitor_launch_agent_status_inner() +} + +fn monitor_launch_agent_path() -> Result { + let home = env::var_os("HOME") + .ok_or_else(|| AppError::config("HOME is not set; cannot resolve LaunchAgents path"))?; + Ok(PathBuf::from(home) + .join("Library") + .join("LaunchAgents") + .join(MONITOR_LAUNCH_AGENT_FILE)) +} + +fn monitor_binary_path() -> Result { + for candidate in monitor_binary_candidates() { + if candidate.is_file() && is_executable(&candidate) { + return Ok(candidate); + } + } + Err(AppError::config( + "Could not find an executable git-same binary for the monitor LaunchAgent", + )) +} + +fn monitor_binary_candidates() -> Vec { + let mut candidates = Vec::new(); + if let Ok(exe) = env::current_exe() { + if let Some(contents) = exe.ancestors().find(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "Contents") + }) { + candidates.push(contents.join("Helpers").join("git-same")); + } + if let Some(parent) = exe.parent() { + candidates.push(parent.join("git-same")); + } + } + if let Some(home) = env::var_os("HOME") { + let home = PathBuf::from(home); + candidates.push(home.join(".cargo/bin/git-same")); + candidates.push(home.join(".cargo/bin/gisa")); + } + if let Some(path) = find_on_path("git-same") { + candidates.push(path); + } + if let Some(path) = find_on_path("gisa") { + candidates.push(path); + } + dedupe_paths(candidates) +} + +fn render_monitor_plist(binary_path: &Path) -> Result { + if !binary_path.is_file() || !is_executable(binary_path) { + return Err(AppError::config(format!( + "Monitor binary is not executable: {}", + binary_path.display() + ))); + } + Ok(MONITOR_PLIST_TEMPLATE.replace( + "__GIT_SAME_MONITOR_BINARY__", + &escape_xml(&binary_path.display().to_string()), + )) +} + +fn escape_xml(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn is_executable(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::metadata(path) + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + path.is_file() + } +} + +fn find_on_path(binary: &str) -> Option { + let paths = env::var_os("PATH")?; + env::split_paths(&paths) + .map(|dir| dir.join(binary)) + .find(|path| path.is_file() && is_executable(path)) +} + +fn dedupe_paths(paths: Vec) -> Vec { + let mut deduped = Vec::new(); + for path in paths { + if !deduped.iter().any(|existing| existing == &path) { + deduped.push(path); + } + } + deduped +} + +fn launchctl_print_monitor() -> std::io::Result { + Command::new("/bin/launchctl") + .args([ + "print", + &format!("{}/{}", launchctl_domain(), MONITOR_LAUNCH_AGENT_LABEL), + ]) + .output() +} + +fn run_launchctl(args: &[&str]) -> Result<(), AppError> { + let output = Command::new("/bin/launchctl") + .args(args) + .output() + .map_err(|error| AppError::config(format!("launchctl failed to start: {error}")))?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + Err(AppError::config(format!( + "launchctl {} failed: {}{}", + args.join(" "), + stdout, + stderr + ))) +} + +fn launchctl_domain() -> String { + let uid = Command::new("/usr/bin/id") + .arg("-u") + .output() + .ok() + .and_then(|output| output.status.success().then_some(output.stdout)) + .and_then(|stdout| String::from_utf8(stdout).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "0".to_string()); + format!("gui/{uid}") +} + +fn launchctl_output_has_pid(output: &str) -> bool { + output.lines().any(|line| { + let trimmed = line.trim_start(); + trimmed.starts_with("pid = ") || trimmed.starts_with("pid =") + }) +} + +fn monitor_agent_state(installed: bool, loaded: bool, running: bool) -> String { + if !installed { + "missing_plist" + } else if !loaded { + "not_loaded" + } else if !running { + "not_running" + } else { + "running" + } + .to_string() +} + +fn monitor_agent_message(state: &str) -> String { + match state { + "missing_plist" => "LaunchAgent plist is missing".to_string(), + "not_loaded" => "LaunchAgent is installed but not loaded".to_string(), + "not_running" => "LaunchAgent is loaded but the monitor is not running".to_string(), + "running" => "Monitor LaunchAgent is running".to_string(), + _ => "Unknown monitor LaunchAgent state".to_string(), + } +} + +// `pluginkit -m -v -i ` prints one line per plugin matching the id, or +// nothing if no match. Each line begins with `+` (enabled) or `-` (disabled), +// followed by the plugin id and bundle path. We treat any line containing +// our id as "installed" and the leading `+` as "enabled". +fn parse_pluginkit_output(stdout: &str, target_id: &str) -> ExtensionStatus { + for line in stdout.lines() { + if line.contains(target_id) { + let enabled = line.trim_start().starts_with('+'); + return ExtensionStatus { + installed: true, + enabled, + }; + } + } + ExtensionStatus { + installed: false, + enabled: false, + } +} + +fn workspace_summaries() -> Result, AppError> { + let config = Config::load()?; + let default_workspace = config.default_workspace.clone(); + let workspaces = WorkspaceManager::list()?; + + Ok(workspaces + .iter() + .map(|workspace| workspace_summary(workspace, default_workspace.as_deref())) + .collect()) +} + +fn ensure_config_file() -> Result { + let path = Config::default_path()?; + if !path.exists() { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + AppError::config(format!( + "Failed to create config directory '{}': {error}", + parent.display() + )) + })?; + } + fs::write(&path, Config::default_toml()).map_err(|error| { + AppError::config(format!( + "Failed to write default config at '{}': {error}", + path.display() + )) + })?; + } + Ok(path) +} + +fn app_config_dto(config: &Config, path: &Path, exists: bool) -> AppConfigDto { + AppConfigDto { + config_path: path.display().to_string(), + exists, + structure: config.structure.clone(), + concurrency: config.concurrency, + sync_mode: sync_mode_label(config.sync_mode), + default_workspace: config.default_workspace.clone(), + refresh_interval: config.refresh_interval, + clone: clone_options_dto(&config.clone), + filters: filter_options_dto(&config.filters), + workspaces: config.workspaces.clone(), + finder: FinderConfigDto { + scan_roots: config.finder.scan_roots.clone(), + max_depth: config.finder.max_depth, + exclude_dirs: config.finder.exclude_dirs.clone(), + show_ambient: config.finder.show_ambient, + }, + monitor: MonitorConfigDto { + fullscan_interval_secs: config.monitor.fullscan_interval_secs, + }, + } +} + +fn app_config_input(input: AppConfigInput) -> Result { + let mut config = Config { + structure: input.structure, + concurrency: input.concurrency, + sync_mode: SyncMode::from_str(&input.sync_mode).map_err(AppError::config)?, + default_workspace: input + .default_workspace + .and_then(|value| clean_optional(Some(value))), + refresh_interval: input.refresh_interval, + clone: clone_options_input(input.clone), + filters: filter_options_input(input.filters), + workspaces: clean_string_list(input.workspaces), + ..Config::default() + }; + config.finder.scan_roots = clean_string_list(input.finder.scan_roots); + config.finder.max_depth = input.finder.max_depth; + config.finder.exclude_dirs = clean_string_list(input.finder.exclude_dirs); + config.finder.show_ambient = input.finder.show_ambient; + config.monitor.fullscan_interval_secs = input.monitor.fullscan_interval_secs; + config.validate()?; + Ok(config) +} + +fn workspace_detail(workspace: &WorkspaceConfig, config: &Config) -> WorkspaceDetailDto { + WorkspaceDetailDto { + id: tilde_collapse_path(&workspace.root_path), + name: workspace_name(&workspace.root_path), + root: workspace.root_path.display().to_string(), + config_path: workspace + .root_path + .join(".git-same") + .join("config.toml") + .display() + .to_string(), + provider: provider_dto(&workspace.provider), + username: workspace.username.clone(), + orgs: workspace.orgs.clone(), + include_repos: workspace.include_repos.clone(), + exclude_repos: workspace.exclude_repos.clone(), + structure: workspace.structure.clone(), + sync_mode: workspace.sync_mode.map(sync_mode_label), + clone_options: workspace.clone_options.as_ref().map(clone_options_dto), + filters: filter_options_dto(&workspace.filters), + concurrency: workspace.concurrency, + refresh_interval: workspace.refresh_interval, + last_synced: workspace.last_synced.clone(), + default: workspace_is_default(workspace, config.default_workspace.as_deref()), + } +} + +fn provider_dto(provider: &WorkspaceProvider) -> WorkspaceProviderDto { + WorkspaceProviderDto { + kind: provider.kind.slug().to_string(), + label: provider.kind.display_name().to_string(), + api_url: provider.api_url.clone(), + prefer_ssh: provider.prefer_ssh, + } +} + +fn provider_input(input: &WorkspaceProviderDto) -> Result { + let kind = ProviderKind::from_str(&input.kind)?; + Ok(WorkspaceProvider { + kind, + api_url: input + .api_url + .clone() + .and_then(|value| clean_optional(Some(value))), + prefer_ssh: input.prefer_ssh, + }) +} + +fn clone_options_dto(options: &ConfigCloneOptions) -> CloneOptionsDto { + CloneOptionsDto { + depth: options.depth, + branch: options.branch.clone(), + recurse_submodules: options.recurse_submodules, + } +} + +fn clone_options_input(input: CloneOptionsDto) -> ConfigCloneOptions { + ConfigCloneOptions { + depth: input.depth, + branch: input.branch, + recurse_submodules: input.recurse_submodules, + } +} + +fn filter_options_dto(filters: &FilterOptions) -> FilterOptionsDto { + FilterOptionsDto { + include_archived: filters.include_archived, + include_forks: filters.include_forks, + orgs: filters.orgs.clone(), + exclude_repos: filters.exclude_repos.clone(), + } +} + +fn filter_options_input(input: FilterOptionsDto) -> FilterOptions { + FilterOptions { + include_archived: input.include_archived, + include_forks: input.include_forks, + orgs: clean_string_list(input.orgs), + exclude_repos: clean_string_list(input.exclude_repos), + } +} + +fn sync_mode_label(sync_mode: SyncMode) -> String { + match sync_mode { + SyncMode::Fetch => "fetch", + SyncMode::Pull => "pull", + } + .to_string() +} + +fn app_requirement_checks() -> Vec { + let config_path = match Config::default_path() { + Ok(path) => path, + Err(error) => { + return vec![RequirementCheckDto { + name: "Config file".to_string(), + passed: false, + message: error.to_string(), + suggestion: Some("Check HOME or GIT_SAME_CONFIG_DIR".to_string()), + critical: true, + }] + } + }; + let config_exists = config_path.exists(); + let mut checks = vec![RequirementCheckDto { + name: "Config file".to_string(), + passed: config_exists, + message: if config_exists { + config_path.display().to_string() + } else { + "not created".to_string() + }, + suggestion: (!config_exists).then(|| "Create the default Git-Same config".to_string()), + critical: true, + }]; + + let snapshot = read_status_snapshot().ok(); + let monitor_agent = monitor_launch_agent_status_inner().ok(); + checks.push(RequirementCheckDto { + name: "Monitor".to_string(), + passed: monitor_agent.as_ref().is_some_and(|agent| agent.running) + && snapshot.as_ref().is_some_and(|snapshot| !snapshot.stale), + message: monitor_requirement_message(monitor_agent.as_ref(), snapshot.as_ref()), + suggestion: monitor_requirement_suggestion(monitor_agent.as_ref(), snapshot.as_ref()), + critical: false, + }); + + let extension = extension_status().ok(); + checks.push(RequirementCheckDto { + name: "Finder extension".to_string(), + passed: extension + .as_ref() + .is_some_and(|extension| extension.installed && extension.enabled), + message: match extension { + Some(ExtensionStatus { + installed: true, + enabled: true, + }) => "installed and enabled".to_string(), + Some(ExtensionStatus { + installed: true, + enabled: false, + }) => "installed but disabled".to_string(), + Some(_) => "not installed".to_string(), + None => "unable to check".to_string(), + }, + suggestion: Some("Enable Git-Same Badges in System Settings".to_string()), + critical: false, + }); + + let fda_needed = snapshot + .as_ref() + .and_then(|snapshot| snapshot.status.as_ref()) + .is_some_and(|status| !status.workspaces.is_empty() && status.repos.is_empty()); + checks.push(RequirementCheckDto { + name: "Full Disk Access".to_string(), + passed: !fda_needed, + message: if fda_needed { + "no repositories visible to the monitor".to_string() + } else { + "not currently required".to_string() + }, + suggestion: fda_needed + .then(|| "Grant Full Disk Access to Git-Same in System Settings".to_string()), + critical: false, + }); + + checks +} + +fn monitor_requirement_message( + agent: Option<&MonitorLaunchAgentStatusDto>, + snapshot: Option<&StatusSnapshot>, +) -> String { + match agent { + Some(agent) if !agent.installed => "LaunchAgent plist missing".to_string(), + Some(agent) if !agent.loaded => "LaunchAgent installed but not loaded".to_string(), + Some(agent) if !agent.running => { + "LaunchAgent loaded but monitor process is not running".to_string() + } + Some(_) if snapshot.is_some_and(|snapshot| snapshot.stale) => { + "Monitor running but status file is stale".to_string() + } + Some(_) => snapshot + .and_then(|snapshot| snapshot.updated_at.clone()) + .unwrap_or_else(|| "Monitor running".to_string()), + None => "Unable to inspect LaunchAgent".to_string(), + } +} + +fn monitor_requirement_suggestion( + agent: Option<&MonitorLaunchAgentStatusDto>, + snapshot: Option<&StatusSnapshot>, +) -> Option { + match agent { + Some(agent) if !agent.installed => { + Some("Install the Git-Same monitor LaunchAgent".to_string()) + } + Some(agent) if !agent.loaded || !agent.running => { + Some("Restart the Git-Same monitor LaunchAgent".to_string()) + } + Some(_) if snapshot.is_some_and(|snapshot| snapshot.stale) => { + Some("Restart the monitor or wait for the next scan".to_string()) + } + Some(_) => None, + None => Some("Check LaunchAgent permissions and the git-same binary path".to_string()), + } +} + +async fn read_workspace_structure_inner( + workspace_id: String, +) -> Result { + let config = Config::load()?; + let workspace = WorkspaceManager::resolve(Some(&workspace_id), &config)?; + let base_path = workspace.expanded_base_path(); + let structure = workspace + .structure + .clone() + .unwrap_or_else(|| config.structure.clone()); + let provider_name = workspace.provider.kind.slug().to_string(); + let orchestrator = workspace_orchestrator(&workspace, &config, structure.clone()); + + let mut source = "cache".to_string(); + let mut cache_age_secs = None; + let mut error = None; + let repos = match load_structure_cache(&workspace, &orchestrator) { + Ok(Some((repos, age_secs))) => { + cache_age_secs = Some(age_secs); + repos + } + Ok(None) | Err(_) => match discover_structure_repos(&workspace, &orchestrator).await { + Ok(repos) => { + source = "remote".to_string(); + save_structure_cache(&workspace, &provider_name, &repos); + repos + } + Err(err) => { + source = "unavailable".to_string(); + error = Some(err); + Vec::new() + } + }, + }; + + Ok(WorkspaceStructureDto { + workspace_id: tilde_collapse_path(&workspace.root_path), + name: workspace_name(&workspace.root_path), + root: base_path.display().to_string(), + provider: workspace.provider.kind.display_name().to_string(), + host: provider_host(&workspace.provider), + source, + cache_age_secs, + error, + repos: structure_repo_dtos(&repos, &base_path, &provider_name, &structure), + }) +} + +fn workspace_orchestrator( + workspace: &WorkspaceConfig, + _config: &Config, + structure: String, +) -> DiscoveryOrchestrator { + let mut filters = workspace.filters.clone(); + if !workspace.orgs.is_empty() { + filters.orgs = workspace.orgs.clone(); + } + filters.exclude_repos = workspace.exclude_repos.clone(); + DiscoveryOrchestrator::new(filters, structure) +} + +fn load_structure_cache( + workspace: &WorkspaceConfig, + orchestrator: &DiscoveryOrchestrator, +) -> anyhow::Result, u64)>> { + let Some(cache) = CacheManager::for_workspace(&workspace.root_path)?.load()? else { + return Ok(None); + }; + let age_secs = cache.age_secs(); + let options = orchestrator.to_discovery_options(); + let repos = cache + .repos + .values() + .flat_map(|provider_repos| provider_repos.iter()) + .filter(|owned| { + options.should_include_org(&owned.owner) && options.should_include(&owned.repo) + }) + .cloned() + .collect(); + Ok(Some((repos, age_secs))) +} + +async fn discover_structure_repos( + workspace: &WorkspaceConfig, + orchestrator: &DiscoveryOrchestrator, +) -> Result, String> { + let provider_cfg = workspace.provider.clone(); + let auth = tokio::task::spawn_blocking(move || get_auth_for_provider(&provider_cfg)) + .await + .map_err(|err| format!("Auth task failed: {err}"))? + .map_err(|err| err.to_string())?; + let provider = + create_provider(&workspace.provider, &auth.token).map_err(|err| err.to_string())?; + orchestrator + .discover(provider.as_ref(), &NoProgress) + .await + .map_err(|err| err.to_string()) +} + +fn save_structure_cache(workspace: &WorkspaceConfig, provider_name: &str, repos: &[OwnedRepo]) { + let Ok(cache_manager) = CacheManager::for_workspace(&workspace.root_path) else { + return; + }; + let mut repos_by_provider = HashMap::new(); + repos_by_provider.insert(provider_name.to_string(), repos.to_vec()); + let cache = DiscoveryCache::new(workspace.username.clone(), repos_by_provider); + let _ = cache_manager.save(&cache); +} + +fn structure_repo_dtos( + repos: &[OwnedRepo], + base_path: &Path, + provider_name: &str, + structure: &str, +) -> Vec { + let template = RepoPathTemplate::new(structure.to_string()); + let mut dtos: Vec<_> = repos + .iter() + .map(|owned| { + let local_path = template.render_owned_repo(base_path, owned, provider_name); + WorkspaceStructureRepoDto { + owner: owned.owner.clone(), + name: owned.repo.name.clone(), + full_name: owned.repo.full_name.clone(), + url: repo_url(owned), + local_exists: local_path.exists(), + local_path: local_path.display().to_string(), + } + }) + .collect(); + dtos.sort_by(|left, right| left.full_name.cmp(&right.full_name)); + dtos +} + +fn repo_url(owned: &OwnedRepo) -> String { + if !owned.repo.clone_url.is_empty() { + return owned.repo.clone_url.trim_end_matches(".git").to_string(); + } + format!("https://github.com/{}", owned.repo.full_name) +} + +fn provider_host(provider: &WorkspaceProvider) -> String { + match provider.kind { + ProviderKind::GitHub => "github.com".to_string(), + _ => provider + .api_url + .clone() + .unwrap_or_else(|| provider.kind.default_api_url().to_string()) + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_end_matches('/') + .to_string(), + } +} + +fn requirement_check_dto(check: CheckResult) -> RequirementCheckDto { + RequirementCheckDto { + name: check.name, + passed: check.passed, + message: check.message, + suggestion: check.suggestion, + critical: check.critical, + } +} + +pub(crate) fn read_status_snapshot() -> Result { + let ipc = IpcConfig::default_path()?; + read_status_snapshot_with(&ipc) +} + +fn read_status_snapshot_with(ipc: &IpcConfig) -> Result { + ipc.ensure_dir()?; + let status_path = ipc.status_file_path(); + let writer = StatusFileWriter::new(status_path.clone()); + let metadata = fs::metadata(&status_path).ok(); + let updated_at = metadata + .as_ref() + .and_then(|meta| meta.modified().ok()) + .map(system_time_to_rfc3339); + let stale_by_age = metadata + .as_ref() + .and_then(|meta| meta.modified().ok()) + .map(|modified| { + modified + .elapsed() + .unwrap_or(Duration::from_secs(DAEMON_STALE_AFTER_SECS + 1)) + > Duration::from_secs(DAEMON_STALE_AFTER_SECS) + }) + .unwrap_or(true); + let monitor_alive = if writer.exists() { + writer + .read() + .map(|status| is_process_alive(status.daemon_pid)) + .unwrap_or(false) + } else { + false + }; + let stale = stale_by_age || !monitor_alive; + let status = if writer.exists() && !stale { + Some(writer.read()?) + } else if writer.exists() { + writer.read().ok() + } else { + None + }; + + Ok(StatusSnapshot { + status_path: status_path.display().to_string(), + updated_at, + stale, + status, + }) +} + +fn workspace_summary( + workspace: &WorkspaceConfig, + default_workspace: Option<&str>, +) -> WorkspaceSummary { + let collapsed = tilde_collapse_path(&workspace.root_path); + let root = workspace.root_path.display().to_string(); + let default = workspace_is_default(workspace, default_workspace); + + WorkspaceSummary { + id: collapsed, + name: workspace_name(&workspace.root_path), + root, + provider: workspace.provider.kind.display_name().to_string(), + org_count: workspace.orgs.len(), + last_sync: workspace.last_synced.clone(), + default, + } +} + +fn workspace_is_default(workspace: &WorkspaceConfig, default_workspace: Option<&str>) -> bool { + let collapsed = tilde_collapse_path(&workspace.root_path); + default_workspace + .map(|value| value == collapsed || same_path_string(value, &workspace.root_path)) + .unwrap_or(false) +} + +fn workspace_name(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or_else(|| path.to_str().unwrap_or("Workspace")) + .to_string() +} + +fn prepare_workspace_root(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(AppError::config("Workspace root is required")); + } + let expanded = shellexpand::tilde(trimmed); + let path = PathBuf::from(expanded.as_ref()); + fs::create_dir_all(&path).map_err(|error| { + AppError::config(format!( + "Failed to create workspace root '{}': {error}", + path.display() + )) + })?; + Ok(fs::canonicalize(&path).unwrap_or(path)) +} + +fn same_path(left: &Path, right: &Path) -> bool { + let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf()); + let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf()); + left == right +} + +fn same_path_string(value: &str, path: &Path) -> bool { + let expanded = shellexpand::tilde(value); + Path::new(expanded.as_ref()) == path +} + +fn clean_optional(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) +} + +fn clean_string_list(values: Vec) -> Vec { + values + .into_iter() + .filter_map(|value| clean_optional(Some(value))) + .collect() +} + +fn system_time_to_rfc3339(time: SystemTime) -> String { + let datetime: chrono::DateTime = time.into(); + datetime.to_rfc3339() +} + +fn is_process_alive(pid: u32) -> bool { + if pid == 0 { + return false; + } + + #[cfg(unix)] + { + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + + #[cfg(not(unix))] + { + let _ = pid; + true + } +} + +fn error_string(error: impl std::fmt::Display) -> String { + error.to_string() +} diff --git a/crates/git-same-app/src/commands_tests.rs b/crates/git-same-app/src/commands_tests.rs new file mode 100644 index 0000000..c9fc0d4 --- /dev/null +++ b/crates/git-same-app/src/commands_tests.rs @@ -0,0 +1,455 @@ +use super::*; +use std::sync::{Mutex, MutexGuard}; + +static CONFIG_ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct ConfigEnvGuard { + _lock: MutexGuard<'static, ()>, + previous: Option, +} + +impl ConfigEnvGuard { + fn new(path: &std::path::Path) -> Self { + let lock = CONFIG_ENV_LOCK.lock().unwrap(); + let previous = std::env::var("GIT_SAME_CONFIG_DIR").ok(); + std::env::set_var("GIT_SAME_CONFIG_DIR", path); + Self { + _lock: lock, + previous, + } + } +} + +impl Drop for ConfigEnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(previous) => std::env::set_var("GIT_SAME_CONFIG_DIR", previous), + None => std::env::remove_var("GIT_SAME_CONFIG_DIR"), + } + } +} + +struct TestDir { + path: std::path::PathBuf, +} + +impl TestDir { + fn new(name: &str) -> Self { + let unique = format!( + "git-same-app-{}-{}-{}", + name, + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap() + ); + let path = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &std::path::Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +fn default_provider_input() -> WorkspaceProviderDto { + WorkspaceProviderDto { + kind: "github".to_string(), + label: "GitHub".to_string(), + api_url: None, + prefer_ssh: true, + } +} + +fn default_filter_input() -> FilterOptionsDto { + FilterOptionsDto { + include_archived: false, + include_forks: false, + orgs: Vec::new(), + exclude_repos: Vec::new(), + } +} + +fn default_clone_input() -> CloneOptionsDto { + CloneOptionsDto { + depth: 0, + branch: String::new(), + recurse_submodules: false, + } +} + +fn workspace_input(root: &std::path::Path) -> WorkspaceInput { + WorkspaceInput { + id: None, + root: root.display().to_string(), + provider: default_provider_input(), + username: "manuel".to_string(), + orgs: vec!["acme".to_string()], + include_repos: vec!["acme/widgets".to_string()], + exclude_repos: vec!["acme/legacy".to_string()], + structure: Some("{org}/{repo}".to_string()), + sync_mode: Some("fetch".to_string()), + clone_options: Some(default_clone_input()), + filters: default_filter_input(), + concurrency: Some(2), + refresh_interval: Some(20), + default: true, + } +} + +#[test] +fn render_monitor_plist_replaces_binary_placeholder() { + let temp = TestDir::new("monitor-plist"); + let binary = temp.path().join("git-same"); + std::fs::write(&binary, "#!/bin/sh\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(&binary).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&binary, permissions).unwrap(); + } + + let rendered = render_monitor_plist(&binary).unwrap(); + + assert!(rendered.contains(&binary.display().to_string())); + assert!(!rendered.contains("__GIT_SAME_MONITOR_BINARY__")); + assert!(rendered.contains("com.zaai.git-same.monitor")); +} + +#[test] +fn render_monitor_plist_rejects_non_executable_binary() { + let temp = TestDir::new("monitor-plist-invalid"); + let binary = temp.path().join("git-same"); + std::fs::write(&binary, "").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(&binary).unwrap().permissions(); + permissions.set_mode(0o644); + std::fs::set_permissions(&binary, permissions).unwrap(); + } + + let error = render_monitor_plist(&binary).unwrap_err().to_string(); + + assert!(error.contains("not executable")); +} + +#[test] +fn monitor_requirement_message_distinguishes_missing_plist() { + let agent = MonitorLaunchAgentStatusDto { + label: MONITOR_LAUNCH_AGENT_LABEL.to_string(), + plist_path: "/tmp/missing.plist".to_string(), + binary_path: None, + installed: false, + loaded: false, + running: false, + state: "missing_plist".to_string(), + message: "LaunchAgent plist is missing".to_string(), + }; + + assert_eq!( + monitor_requirement_message(Some(&agent), None), + "LaunchAgent plist missing" + ); + assert_eq!( + monitor_requirement_suggestion(Some(&agent), None), + Some("Install the Git-Same monitor LaunchAgent".to_string()) + ); +} + +#[test] +fn read_status_snapshot_returns_none_when_status_file_is_missing() { + let temp = TestDir::new("missing-status"); + let ipc = IpcConfig { + dir: temp.path().join("ipc"), + }; + + let snapshot = read_status_snapshot_with(&ipc).unwrap(); + + assert!(snapshot.stale); + assert!(snapshot.updated_at.is_none()); + assert!( + snapshot.status.is_none(), + "missing status file must not trigger a fallback scan" + ); +} + +#[test] +fn read_status_snapshot_returns_last_known_status_when_monitor_pid_is_stale() { + let temp = TestDir::new("stale-monitor"); + let ipc = IpcConfig { + dir: temp.path().join("ipc"), + }; + ipc.ensure_dir().unwrap(); + + let mut stale_status = FinderStatus::new(u32::MAX, chrono::Utc::now().to_rfc3339()); + stale_status.repos = Vec::new(); + StatusFileWriter::new(ipc.status_file_path()) + .write(&stale_status) + .unwrap(); + + let snapshot = read_status_snapshot_with(&ipc).unwrap(); + + assert!(snapshot.stale); + assert!(snapshot.updated_at.is_some()); + let status = snapshot + .status + .expect("stale monitor should still surface the last-known status from disk"); + assert!(status.repos.is_empty()); +} + +#[test] +fn ensure_config_creates_default_config() { + let temp = TestDir::new("ensure-config"); + let _env = ConfigEnvGuard::new(temp.path()); + + let config = ensure_config().unwrap(); + + assert!(config.exists); + assert!(std::path::Path::new(&config.config_path).exists()); + assert_eq!(config.sync_mode, "fetch"); + assert_eq!(config.structure, "{org}/{repo}"); +} + +#[test] +fn save_app_config_round_trips_structured_fields() { + let temp = TestDir::new("save-config"); + let _env = ConfigEnvGuard::new(temp.path()); + ensure_config().unwrap(); + + let saved = save_app_config(AppConfigInput { + structure: "{provider}/{org}/{repo}".to_string(), + concurrency: 3, + sync_mode: "pull".to_string(), + default_workspace: Some("~/repos".to_string()), + refresh_interval: 60, + clone: CloneOptionsDto { + depth: 1, + branch: "main".to_string(), + recurse_submodules: true, + }, + filters: FilterOptionsDto { + include_archived: true, + include_forks: true, + orgs: vec!["acme".to_string(), " ".to_string()], + exclude_repos: vec!["acme/skip".to_string()], + }, + workspaces: vec!["~/repos".to_string()], + finder: FinderConfigDto { + scan_roots: vec!["~/Code".to_string()], + max_depth: 5, + exclude_dirs: vec!["node_modules".to_string(), "target".to_string()], + show_ambient: false, + }, + monitor: MonitorConfigDto { + fullscan_interval_secs: 90, + }, + }) + .unwrap(); + let loaded = read_app_config().unwrap(); + + assert_eq!(saved, loaded); + assert_eq!(loaded.structure, "{provider}/{org}/{repo}"); + assert_eq!(loaded.sync_mode, "pull"); + assert_eq!(loaded.clone.depth, 1); + assert_eq!(loaded.filters.orgs, vec!["acme"]); + assert!(!loaded.finder.show_ambient); + assert_eq!(loaded.monitor.fullscan_interval_secs, 90); +} + +#[test] +fn workspace_save_and_delete_only_remove_metadata() { + let temp = TestDir::new("workspace-crud"); + let _env = ConfigEnvGuard::new(temp.path()); + ensure_config().unwrap(); + let root = temp.path().join("workspace"); + let repo_file = root.join("acme/widgets/keep.txt"); + std::fs::create_dir_all(repo_file.parent().unwrap()).unwrap(); + std::fs::write(&repo_file, "keep").unwrap(); + + let detail = save_workspace(workspace_input(&root)).unwrap(); + + assert_eq!(detail.name, "workspace"); + assert!(detail.default); + assert!(root.join(".git-same/config.toml").exists()); + assert_eq!( + read_workspace(detail.id.clone()).unwrap().orgs, + vec!["acme"] + ); + + let workspaces = delete_workspace(detail.id).unwrap(); + + assert!(workspaces.is_empty()); + assert!(!root.join(".git-same").exists()); + assert!(repo_file.exists()); + let config = Config::load().unwrap(); + assert!(config.default_workspace.is_none()); + assert!(config.workspaces.is_empty()); +} + +#[test] +fn set_default_workspace_can_set_and_clear_default() { + let temp = TestDir::new("workspace-default"); + let _env = ConfigEnvGuard::new(temp.path()); + ensure_config().unwrap(); + let root = temp.path().join("workspace"); + let detail = save_workspace(WorkspaceInput { + default: false, + ..workspace_input(&root) + }) + .unwrap(); + + let listed = set_default_workspace(Some(detail.id.clone())).unwrap(); + assert_eq!(listed.len(), 1); + assert!(listed[0].default); + + let listed = set_default_workspace(None).unwrap(); + assert_eq!(listed.len(), 1); + assert!(!listed[0].default); +} + +#[test] +fn save_workspace_can_clear_existing_default() { + let temp = TestDir::new("workspace-clear-default"); + let _env = ConfigEnvGuard::new(temp.path()); + ensure_config().unwrap(); + let root = temp.path().join("workspace"); + let detail = save_workspace(workspace_input(&root)).unwrap(); + + let updated = save_workspace(WorkspaceInput { + id: Some(detail.id), + default: false, + ..workspace_input(&root) + }) + .unwrap(); + + assert!(!updated.default); + assert!(Config::load().unwrap().default_workspace.is_none()); +} + +#[tokio::test] +async fn read_workspace_structure_uses_discovery_cache() { + let temp = TestDir::new("workspace-structure"); + let _env = ConfigEnvGuard::new(temp.path()); + ensure_config().unwrap(); + let root = temp.path().join("workspace"); + let detail = save_workspace(WorkspaceInput { + structure: Some("{provider}/{org}/{repo}".to_string()), + ..workspace_input(&root) + }) + .unwrap(); + let local_repo = root.join("github/acme/widgets"); + std::fs::create_dir_all(&local_repo).unwrap(); + + let repo = git_same_core::types::Repo { + id: 42, + name: "widgets".to_string(), + full_name: "acme/widgets".to_string(), + ssh_url: "git@github.com:acme/widgets.git".to_string(), + clone_url: "https://github.com/acme/widgets.git".to_string(), + default_branch: "main".to_string(), + private: false, + archived: false, + fork: false, + pushed_at: None, + description: None, + }; + let cache = DiscoveryCache::new( + "manuel".to_string(), + HashMap::from([("github".to_string(), vec![OwnedRepo::new("acme", repo)])]), + ); + CacheManager::for_workspace(&root) + .unwrap() + .save(&cache) + .unwrap(); + + let structure = read_workspace_structure_inner(detail.id).await.unwrap(); + + assert_eq!(structure.source, "cache"); + assert_eq!(structure.host, "github.com"); + assert_eq!(structure.repos.len(), 1); + assert_eq!(structure.repos[0].full_name, "acme/widgets"); + assert_eq!(structure.repos[0].url, "https://github.com/acme/widgets"); + assert_eq!( + std::fs::canonicalize(&structure.repos[0].local_path).unwrap(), + std::fs::canonicalize(&local_repo).unwrap() + ); + assert!(structure.repos[0].local_exists); +} + +#[test] +fn requirement_check_dto_maps_core_result() { + let dto = requirement_check_dto(CheckResult { + name: "Git".to_string(), + passed: true, + message: "git version 2.0".to_string(), + suggestion: None, + critical: true, + }); + + assert_eq!(dto.name, "Git"); + assert!(dto.passed); + assert_eq!(dto.message, "git version 2.0"); + assert!(dto.critical); +} + +#[test] +fn parse_pluginkit_output_marks_enabled_extension() { + let stdout = "+ com.zaai.git-same.badges(3.1.0) \ + /Applications/Git-Same.app/Contents/PlugIns/GitSameBadges.appex\n"; + let result = parse_pluginkit_output(stdout, FINDER_EXTENSION_ID); + assert_eq!( + result, + ExtensionStatus { + installed: true, + enabled: true, + } + ); +} + +#[test] +fn parse_pluginkit_output_marks_disabled_extension() { + let stdout = "- com.zaai.git-same.badges(3.1.0) \ + /Applications/Git-Same.app/Contents/PlugIns/GitSameBadges.appex\n"; + let result = parse_pluginkit_output(stdout, FINDER_EXTENSION_ID); + assert_eq!( + result, + ExtensionStatus { + installed: true, + enabled: false, + } + ); +} + +#[test] +fn parse_pluginkit_output_returns_uninstalled_for_empty_stdout() { + let result = parse_pluginkit_output("", FINDER_EXTENSION_ID); + assert_eq!( + result, + ExtensionStatus { + installed: false, + enabled: false, + } + ); +} + +#[test] +fn parse_pluginkit_output_ignores_other_extensions() { + let stdout = "+ com.apple.dt.Xcode.SimulatorTrampoline(15.0) \ + /Applications/Xcode.app/Contents/PlugIns/SimulatorTrampoline.appex\n\ + - com.example.other(1.0) /Applications/Other.app\n"; + let result = parse_pluginkit_output(stdout, FINDER_EXTENSION_ID); + assert_eq!( + result, + ExtensionStatus { + installed: false, + enabled: false, + } + ); +} diff --git a/crates/git-same-app/src/main.rs b/crates/git-same-app/src/main.rs new file mode 100644 index 0000000..211f05e --- /dev/null +++ b/crates/git-same-app/src/main.rs @@ -0,0 +1,35 @@ +mod commands; +mod status_stream; + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .invoke_handler(tauri::generate_handler![ + commands::list_workspaces, + commands::read_app_config, + commands::save_app_config, + commands::ensure_config, + commands::read_workspace, + commands::save_workspace, + commands::delete_workspace, + commands::set_default_workspace, + commands::check_requirements, + commands::monitor_launch_agent_status, + commands::install_monitor_launch_agent, + commands::restart_monitor_launch_agent, + commands::discover_provider_orgs, + commands::read_workspace_structure, + commands::read_status, + commands::start_sync, + commands::extension_status, + commands::open_url, + ]) + .setup(|app| { + if let Err(error) = status_stream::spawn_watcher(app.handle().clone()) { + eprintln!("failed to start status watcher: {error}"); + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running Git-Same"); +} diff --git a/crates/git-same-app/src/status_stream.rs b/crates/git-same-app/src/status_stream.rs new file mode 100644 index 0000000..8006a0b --- /dev/null +++ b/crates/git-same-app/src/status_stream.rs @@ -0,0 +1,42 @@ +use crate::commands::read_status_snapshot; +use git_same_core::ipc::IpcConfig; +use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; +use tauri::{AppHandle, Emitter}; + +pub fn spawn_watcher(app: AppHandle) -> anyhow::Result<()> { + let ipc = IpcConfig::default_path()?; + ipc.ensure_dir()?; + let watch_path = ipc.dir.clone(); + + std::thread::Builder::new() + .name("git-same-status-watcher".to_string()) + .spawn(move || { + let (tx, rx) = std::sync::mpsc::channel(); + let mut watcher = match RecommendedWatcher::new(tx, Config::default()) { + Ok(watcher) => watcher, + Err(error) => { + eprintln!("failed to create status watcher: {error}"); + return; + } + }; + + if let Err(error) = watcher.watch(&watch_path, RecursiveMode::NonRecursive) { + eprintln!( + "failed to watch status directory '{}': {error}", + watch_path.display() + ); + return; + } + + for event in rx { + if event.is_err() { + continue; + } + if let Ok(snapshot) = read_status_snapshot() { + let _ = app.emit("status-updated", snapshot); + } + } + })?; + + Ok(()) +} diff --git a/crates/git-same-app/tauri.conf.json b/crates/git-same-app/tauri.conf.json new file mode 100644 index 0000000..cdb336f --- /dev/null +++ b/crates/git-same-app/tauri.conf.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Git-Same", + "version": "3.1.0", + "identifier": "com.zaai.git-same", + "build": { + "beforeDevCommand": "corepack pnpm dev", + "beforeBuildCommand": "corepack pnpm build", + "devUrl": "http://127.0.0.1:1420", + "frontendDist": "ui/dist" + }, + "app": { + "windows": [ + { + "title": "Git-Same", + "width": 1100, + "height": 720, + "minWidth": 820, + "minHeight": 560, + "decorations": true, + "fullscreen": false + } + ], + "security": { + "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'" + } + }, + "bundle": { + "active": true, + "targets": ["app", "dmg"], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "macOS": { + "minimumSystemVersion": "13.0", + "signingIdentity": null, + "entitlements": null + } + } +} diff --git a/crates/git-same-app/ui/index.html b/crates/git-same-app/ui/index.html new file mode 100644 index 0000000..eaa1773 --- /dev/null +++ b/crates/git-same-app/ui/index.html @@ -0,0 +1,12 @@ + + + + + + Git-Same + + +
+ + + diff --git a/crates/git-same-app/ui/package.json b/crates/git-same-app/ui/package.json new file mode 100644 index 0000000..67a9aa1 --- /dev/null +++ b/crates/git-same-app/ui/package.json @@ -0,0 +1,31 @@ +{ + "name": "git-same-app-ui", + "private": true, + "version": "3.1.0", + "type": "module", + "packageManager": "pnpm@11.0.9+sha512.34ce82e6780233cf9cad8685029a8f81d2e06196c5a9bad98879f7424940c6817c4e4524fb7d38b8553ceed48b9758b8ebaf1abd3600c232c4c8cf7366086f38", + "scripts": { + "dev": "vite --host 127.0.0.1 --port ${GIT_SAME_APP_PORT:-${CONDUCTOR_PORT:-${PORT:-1420}}} --strictPort", + "build": "vite build", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "dependencies": { + "@lucide/svelte": "^1.21.0", + "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-dialog": "^2.7.1", + "svelte": "^5.56.3", + "svelte-spa-router": "^5.1.0" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tauri-apps/cli": "^2.11.3", + "svelte-check": "^4.6.0", + "typescript": "^6.0.3", + "vite": "^8.0.16" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + } +} diff --git a/crates/git-same-app/ui/pnpm-lock.yaml b/crates/git-same-app/ui/pnpm-lock.yaml new file mode 100644 index 0000000..1496c06 --- /dev/null +++ b/crates/git-same-app/ui/pnpm-lock.yaml @@ -0,0 +1,935 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@lucide/svelte': + specifier: ^1.21.0 + version: 1.21.0(svelte@5.56.3) + '@tauri-apps/api': + specifier: ^2.11.1 + version: 2.11.1 + '@tauri-apps/plugin-dialog': + specifier: ^2.7.1 + version: 2.7.1 + svelte: + specifier: ^5.56.3 + version: 5.56.3 + svelte-spa-router: + specifier: ^5.1.0 + version: 5.1.0(svelte@5.56.3) + devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: ^7.1.2 + version: 7.1.2(svelte@5.56.3)(vite@8.0.16) + '@tauri-apps/cli': + specifier: ^2.11.3 + version: 2.11.3 + svelte-check: + specifier: ^4.6.0 + version: 4.6.0(picomatch@4.0.4)(svelte@5.56.3)(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.0.16 + version: 8.0.16 + +packages: + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lucide/svelte@1.21.0': + resolution: {integrity: sha512-MEv//A7Jv3kHukZowv/DWp1MAtUzJKYwtJsmnQ7X98lCgtac3z3NbaToDl3Q6jO3gS9sougFpcD+t+YuxOkRMw==} + peerDependencies: + svelte: ^5 + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sveltejs/acorn-typescript@1.0.10': + resolution: {integrity: sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/load-config@0.1.1': + resolution: {integrity: sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==} + engines: {node: '>= 18.0.0'} + + '@sveltejs/vite-plugin-svelte@7.1.2': + resolution: {integrity: sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.46.4 + vite: ^8.0.0-beta.7 || ^8.0.0 + + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + + '@tauri-apps/cli-darwin-arm64@2.11.3': + resolution: {integrity: sha512-BxpaM8bsCoXs3wd4WKYhas/G1gs7+r7B+e4WnyRk2GEoVOouJB1hoL6E6YLXZDXbYci6VFdrNnobQwd2uVL4ew==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.11.3': + resolution: {integrity: sha512-DbZYuPB1ZEzcAHYeyCvo3ltzM27+aXwPloCrtexPnmgPgulYJm3TOq6aC4S+wPhSXteddg8zImtNkvx/gQzmwg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.3': + resolution: {integrity: sha512-741NduqBmz1XkdU8yz3OI/kBZtqHbvxo9F9ytIeWYU69/Ba9dcZEbqOU++Dp0G/XU8vAI0TfTywEl+p+BbLvaA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.11.3': + resolution: {integrity: sha512-RWAXT8pTqIczXcoic+LXlo6uEbAXGB0cgh6Pg7Y9xVnEbzryQ1JHtRGj9SxzrKSemBIDBH6Qc24kK2G69i8ofA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-arm64-musl@2.11.3': + resolution: {integrity: sha512-qomqYS+yAkd0gXMRmhguWXc7RfVN+XKKXaEwbf5QmKURwydLFOTldd6F8/WoZDSsBMrV8dpNxz0YneGLmobiSA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.3': + resolution: {integrity: sha512-jOCXbDqeDj5XcclsOBAaXjtTgwZCVg8zEZ+dbPUCoADOgljFgL0rOkYTc96vUYgOrYEfuHYihWMxIDGaD6GwJw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-gnu@2.11.3': + resolution: {integrity: sha512-+u3HO/F3gHwL48t9gWN/urqZvpaEJzBFmTaq5eSIhvy8TOvnhb+LgJr3Q3BG+5JxuBrCUjqtOEz6gMttdJFSBA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-musl@2.11.3': + resolution: {integrity: sha512-spr5Jpr6KF/vehkLwJ0YmdGv8QwpWU+uw7J8bgijO0sox6ZCYsSNMbcsQjTqPi4xl+p0woIYpWXgChgHYpAc8g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-win32-arm64-msvc@2.11.3': + resolution: {integrity: sha512-abkoRQih5xBa3vz2spWaex0kP/MzVzVPQHom2f8jnCq46R/luOD6Uy85EMU9/bfzf6ZzdorWJsgO+OMX90Fx2w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.11.3': + resolution: {integrity: sha512-Vy6AvzFm1G40hg3r+OYDB3jkuu7R4wnMzbQBKuun9v6Cgg8IierpLL7toMzrZKs/8NlG8Sg4x1iLFR52oknyHg==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.11.3': + resolution: {integrity: sha512-GlciF75GdbseajOyib2aCHwE3BXIqZ1liGKWLFRvCdN5wm8h8hFssEVKQ/6E+2jsMLg9v7LCTb983YFnn0QSww==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.11.3': + resolution: {integrity: sha512-EElQe8z8uD7Pi5++tJ/UfEwWuK08rd3oCDYdeIbJAb6pZRrxlqmoF5gh5H5YvzmUPhS4IRCaLSsQhvWkrfK+GQ==} + engines: {node: '>= 10'} + hasBin: true + + '@tauri-apps/plugin-dialog@2.7.1': + resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.2.11: + resolution: {integrity: sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + regexparam@2.0.2: + resolution: {integrity: sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==} + engines: {node: '>=8'} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + svelte-check@4.6.0: + resolution: {integrity: sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' + + svelte-spa-router@5.1.0: + resolution: {integrity: sha512-YGtXOF+advJnQBvS4mvRnHVUF0qpSI2pUrSxidz4s7X9x80IKEgYzupx6j46gZcIcN3BYjmuLUbsgQv1S5bZzQ==} + peerDependencies: + svelte: ^5.0.0 + + svelte@5.56.3: + resolution: {integrity: sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lucide/svelte@1.21.0(svelte@5.56.3)': + dependencies: + svelte: 5.56.3 + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.133.0': {} + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sveltejs/acorn-typescript@1.0.10(acorn@8.17.0)': + dependencies: + acorn: 8.17.0 + + '@sveltejs/load-config@0.1.1': {} + + '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16)': + dependencies: + deepmerge: 4.3.1 + magic-string: 0.30.21 + obug: 2.1.3 + svelte: 5.56.3 + vite: 8.0.16 + vitefu: 1.1.3(vite@8.0.16) + + '@tauri-apps/api@2.11.1': {} + + '@tauri-apps/cli-darwin-arm64@2.11.3': + optional: true + + '@tauri-apps/cli-darwin-x64@2.11.3': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.3': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.11.3': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.11.3': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.3': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.11.3': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.11.3': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.11.3': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.11.3': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.11.3': + optional: true + + '@tauri-apps/cli@2.11.3': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.11.3 + '@tauri-apps/cli-darwin-x64': 2.11.3 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.3 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.3 + '@tauri-apps/cli-linux-arm64-musl': 2.11.3 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.3 + '@tauri-apps/cli-linux-x64-gnu': 2.11.3 + '@tauri-apps/cli-linux-x64-musl': 2.11.3 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.3 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.3 + '@tauri-apps/cli-win32-x64-msvc': 2.11.3 + + '@tauri-apps/plugin-dialog@2.7.1': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/estree@1.0.9': {} + + '@types/trusted-types@2.0.7': {} + + acorn@8.17.0: {} + + aria-query@5.3.1: {} + + axobject-query@4.1.0: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clsx@2.1.1: {} + + deepmerge@4.3.1: {} + + detect-libc@2.1.2: {} + + devalue@5.8.1: {} + + esm-env@1.2.2: {} + + esrap@2.2.11: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-character@3.0.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mri@1.2.0: {} + + nanoid@3.3.12: {} + + obug@2.1.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + readdirp@4.1.2: {} + + regexparam@2.0.2: {} + + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + source-map-js@1.2.1: {} + + svelte-check@4.6.0(picomatch@4.0.4)(svelte@5.56.3)(typescript@6.0.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@sveltejs/load-config': 0.1.1 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.56.3 + typescript: 6.0.3 + transitivePeerDependencies: + - picomatch + + svelte-spa-router@5.1.0(svelte@5.56.3): + dependencies: + regexparam: 2.0.2 + svelte: 5.56.3 + + svelte@5.56.3: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.10(acorn@8.17.0) + '@types/estree': 1.0.9 + '@types/trusted-types': 2.0.7 + acorn: 8.17.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.8.1 + esm-env: 1.2.2 + esrap: 2.2.11 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + vite@8.0.16: + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitefu@1.1.3(vite@8.0.16): + optionalDependencies: + vite: 8.0.16 + + zimmerframe@1.1.4: {} diff --git a/crates/git-same-app/ui/pnpm-workspace.yaml b/crates/git-same-app/ui/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/crates/git-same-app/ui/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/crates/git-same-app/ui/src/App.svelte b/crates/git-same-app/ui/src/App.svelte new file mode 100644 index 0000000..98b68f8 --- /dev/null +++ b/crates/git-same-app/ui/src/App.svelte @@ -0,0 +1,78 @@ + + + + Git-Same + + +
+ +
+ +
+ + +
+
+
+ + diff --git a/crates/git-same-app/ui/src/lib/BadgeChip.svelte b/crates/git-same-app/ui/src/lib/BadgeChip.svelte new file mode 100644 index 0000000..0d39b32 --- /dev/null +++ b/crates/git-same-app/ui/src/lib/BadgeChip.svelte @@ -0,0 +1,75 @@ + + + + + {badgeLabel(badge)} + {#if count !== null} + {count} + {/if} + + + diff --git a/crates/git-same-app/ui/src/lib/Banner.svelte b/crates/git-same-app/ui/src/lib/Banner.svelte new file mode 100644 index 0000000..80af50d --- /dev/null +++ b/crates/git-same-app/ui/src/lib/Banner.svelte @@ -0,0 +1,198 @@ + + +{#if showError} + +{:else if showProgress} + +{:else if showStale} + +{:else if showAllowExt} + +{:else if showFda} + +{/if} + + diff --git a/crates/git-same-app/ui/src/lib/BrandLogo.svelte b/crates/git-same-app/ui/src/lib/BrandLogo.svelte new file mode 100644 index 0000000..e7501d3 --- /dev/null +++ b/crates/git-same-app/ui/src/lib/BrandLogo.svelte @@ -0,0 +1,50 @@ +
+ +

Mirror GitHub structure to local disk.

+
+ + diff --git a/crates/git-same-app/ui/src/lib/EmptyState.svelte b/crates/git-same-app/ui/src/lib/EmptyState.svelte new file mode 100644 index 0000000..97f9673 --- /dev/null +++ b/crates/git-same-app/ui/src/lib/EmptyState.svelte @@ -0,0 +1,36 @@ + + +
+ {title} + {#if detail} +

{detail}

+ {/if} + +
+ + diff --git a/crates/git-same-app/ui/src/lib/NavItem.svelte b/crates/git-same-app/ui/src/lib/NavItem.svelte new file mode 100644 index 0000000..0ef208f --- /dev/null +++ b/crates/git-same-app/ui/src/lib/NavItem.svelte @@ -0,0 +1,61 @@ + + + + + diff --git a/crates/git-same-app/ui/src/lib/RepoTable.svelte b/crates/git-same-app/ui/src/lib/RepoTable.svelte new file mode 100644 index 0000000..0f82a88 --- /dev/null +++ b/crates/git-same-app/ui/src/lib/RepoTable.svelte @@ -0,0 +1,139 @@ + + +
+
+ Repository + State + Branch + Changes + Remote +
+ {#if workspaceRepos.length === 0} +
No status rows
+ {:else} + {#each workspaceRepos.slice(0, 200) as repo} +
+
+ {repoName(repo.path)} + {repo.org ?? repo.workspace ?? repo.path} +
+ {badgeLabel(repo.badge)} + {repo.current_branch} + {repo.staged_count + repo.unstaged_count + repo.untracked_count} + {repo.ahead} ahead / {repo.behind} behind +
+ {/each} + {/if} +
+ + diff --git a/crates/git-same-app/ui/src/lib/Sidebar.svelte b/crates/git-same-app/ui/src/lib/Sidebar.svelte new file mode 100644 index 0000000..5f454c4 --- /dev/null +++ b/crates/git-same-app/ui/src/lib/Sidebar.svelte @@ -0,0 +1,226 @@ + + + + + diff --git a/crates/git-same-app/ui/src/lib/StatStrip.svelte b/crates/git-same-app/ui/src/lib/StatStrip.svelte new file mode 100644 index 0000000..5b4167c --- /dev/null +++ b/crates/git-same-app/ui/src/lib/StatStrip.svelte @@ -0,0 +1,58 @@ + + +
+
{counts.total}Total
+
{counts.green}Synced
+
{counts.blue}Local config
+
{counts.orange}Branches
+
{counts.red}Local work
+
+ + diff --git a/crates/git-same-app/ui/src/lib/StatusBanner.svelte b/crates/git-same-app/ui/src/lib/StatusBanner.svelte new file mode 100644 index 0000000..481534a --- /dev/null +++ b/crates/git-same-app/ui/src/lib/StatusBanner.svelte @@ -0,0 +1,217 @@ + + +{#if showSuccess} + +{:else if showError} + +{:else if showProgress} + +{:else if showStale} + +{:else if showAllowExt} + +{:else if showFda} + +{/if} + + diff --git a/crates/git-same-app/ui/src/lib/TitleBar.svelte b/crates/git-same-app/ui/src/lib/TitleBar.svelte new file mode 100644 index 0000000..932d011 --- /dev/null +++ b/crates/git-same-app/ui/src/lib/TitleBar.svelte @@ -0,0 +1,194 @@ + + +
+
+

{title}

+

{subtitle}

+
+
+ + {#if showRecheck} + + {/if} + {#if showSync} + + {/if} +
+
+ + diff --git a/crates/git-same-app/ui/src/lib/Topbar.svelte b/crates/git-same-app/ui/src/lib/Topbar.svelte new file mode 100644 index 0000000..7bbe1a2 --- /dev/null +++ b/crates/git-same-app/ui/src/lib/Topbar.svelte @@ -0,0 +1,111 @@ + + +
+
+

{title}

+

{subtitle}

+
+
+ + {#if !isSettings} + + {/if} +
+
+ + diff --git a/crates/git-same-app/ui/src/lib/tauri.ts b/crates/git-same-app/ui/src/lib/tauri.ts new file mode 100644 index 0000000..ab59b44 --- /dev/null +++ b/crates/git-same-app/ui/src/lib/tauri.ts @@ -0,0 +1,113 @@ +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { open } from '@tauri-apps/plugin-dialog'; +import type { + AppConfigDto, + AppConfigInput, + ExtensionStatus, + MonitorLaunchAgentStatusDto, + ProviderDiscoveryDto, + RequirementCheckDto, + StatusSnapshot, + SyncProgressPayload, + WorkspaceDetailDto, + WorkspaceInput, + WorkspaceProviderDto, + WorkspaceStructureDto, + WorkspaceSummary, +} from './types'; + +export function listWorkspaces(): Promise { + return invoke('list_workspaces'); +} + +export function readAppConfig(): Promise { + return invoke('read_app_config'); +} + +export function ensureConfig(): Promise { + return invoke('ensure_config'); +} + +export function saveAppConfig(input: AppConfigInput): Promise { + return invoke('save_app_config', { input }); +} + +export function readWorkspace(workspaceId: string): Promise { + return invoke('read_workspace', { workspaceId }); +} + +export function saveWorkspace(input: WorkspaceInput): Promise { + return invoke('save_workspace', { input }); +} + +export function deleteWorkspace(workspaceId: string): Promise { + return invoke('delete_workspace', { workspaceId }); +} + +export function setDefaultWorkspace( + workspaceId: string | null, +): Promise { + return invoke('set_default_workspace', { workspaceId }); +} + +export function checkRequirements(): Promise { + return invoke('check_requirements'); +} + +export function monitorLaunchAgentStatus(): Promise { + return invoke('monitor_launch_agent_status'); +} + +export function installMonitorLaunchAgent(): Promise { + return invoke('install_monitor_launch_agent'); +} + +export function restartMonitorLaunchAgent(): Promise { + return invoke('restart_monitor_launch_agent'); +} + +export function discoverProviderOrgs( + provider: WorkspaceProviderDto, +): Promise { + return invoke('discover_provider_orgs', { provider }); +} + +export function readWorkspaceStructure( + workspaceId: string, +): Promise { + return invoke('read_workspace_structure', { workspaceId }); +} + +export function readStatus(): Promise { + return invoke('read_status'); +} + +export function startSync(workspaceId: string): Promise { + return invoke('start_sync', { workspaceId }); +} + +export function readExtensionStatus(): Promise { + return invoke('extension_status'); +} + +export function openUrl(url: string): Promise { + return invoke('open_url', { url }); +} + +export async function chooseFolder(defaultPath?: string): Promise { + const selected = await open({ + directory: true, + multiple: false, + defaultPath, + }); + return typeof selected === 'string' ? selected : null; +} + +export function onStatusUpdated(callback: (snapshot: StatusSnapshot) => void) { + return listen('status-updated', (event) => callback(event.payload)); +} + +export function onSyncProgress(callback: (payload: SyncProgressPayload) => void) { + return listen('sync-progress', (event) => callback(event.payload)); +} diff --git a/crates/git-same-app/ui/src/lib/types.ts b/crates/git-same-app/ui/src/lib/types.ts new file mode 100644 index 0000000..ed3753a --- /dev/null +++ b/crates/git-same-app/ui/src/lib/types.ts @@ -0,0 +1,306 @@ +export type Badge = 'green' | 'blue' | 'orange' | 'red' | 'gray'; + +export type SyncMode = 'fetch' | 'pull'; + +export interface WorkspaceSummary { + id: string; + name: string; + root: string; + provider: string; + org_count: number; + last_sync: string | null; + default: boolean; +} + +export interface CloneOptionsDto { + depth: number; + branch: string; + recurse_submodules: boolean; +} + +export interface FilterOptionsDto { + include_archived: boolean; + include_forks: boolean; + orgs: string[]; + exclude_repos: string[]; +} + +export interface FinderConfigDto { + scan_roots: string[]; + max_depth: number; + exclude_dirs: string[]; + show_ambient: boolean; +} + +export interface MonitorConfigDto { + fullscan_interval_secs: number; +} + +export interface AppConfigDto { + config_path: string; + exists: boolean; + structure: string; + concurrency: number; + sync_mode: SyncMode; + default_workspace: string | null; + refresh_interval: number; + clone: CloneOptionsDto; + filters: FilterOptionsDto; + workspaces: string[]; + finder: FinderConfigDto; + monitor: MonitorConfigDto; +} + +export type AppConfigInput = Omit; + +export interface WorkspaceProviderDto { + kind: string; + label: string; + api_url: string | null; + prefer_ssh: boolean; +} + +export interface WorkspaceDetailDto { + id: string; + name: string; + root: string; + config_path: string; + provider: WorkspaceProviderDto; + username: string; + orgs: string[]; + include_repos: string[]; + exclude_repos: string[]; + structure: string | null; + sync_mode: SyncMode | null; + clone_options: CloneOptionsDto | null; + filters: FilterOptionsDto; + concurrency: number | null; + refresh_interval: number | null; + last_synced: string | null; + default: boolean; +} + +export interface WorkspaceInput { + id: string | null; + root: string; + provider: WorkspaceProviderDto; + username: string; + orgs: string[]; + include_repos: string[]; + exclude_repos: string[]; + structure: string | null; + sync_mode: SyncMode | null; + clone_options: CloneOptionsDto | null; + filters: FilterOptionsDto; + concurrency: number | null; + refresh_interval: number | null; + default: boolean; +} + +export interface RequirementCheckDto { + name: string; + passed: boolean; + message: string; + suggestion: string | null; + critical: boolean; +} + +export interface MonitorLaunchAgentStatusDto { + label: string; + plist_path: string; + binary_path: string | null; + installed: boolean; + loaded: boolean; + running: boolean; + state: string; + message: string; +} + +export interface ProviderOrgDto { + name: string; + repo_count: number; + selected: boolean; +} + +export interface ProviderDiscoveryDto { + username: string | null; + orgs: ProviderOrgDto[]; +} + +export interface WorkspaceStructureRepoDto { + owner: string; + name: string; + full_name: string; + url: string; + local_path: string; + local_exists: boolean; +} + +export interface WorkspaceStructureDto { + workspace_id: string; + name: string; + root: string; + provider: string; + host: string; + source: 'cache' | 'remote' | 'unavailable' | string; + cache_age_secs: number | null; + error: string | null; + repos: WorkspaceStructureRepoDto[]; +} + +export interface FinderWorkspaceInfo { + name: string; + root: string; + orgs: string[]; +} + +export interface FinderBranchInfo { + name: string; + upstream?: string; + ahead: number; + behind: number; + synced: boolean; +} + +export interface FinderRemoteInfo { + name: string; + url: string; +} + +export interface FinderWorktreeInfo { + path: string; + branch?: string; + synced: boolean; +} + +export interface OrgFolderInfo { + path: string; + org: string; + workspace: string; + owner_type: 'user' | 'organization' | 'unknown'; +} + +export interface FinderRepoStatus { + path: string; + workspace?: string; + org?: string; + badge: Badge; + current_branch: string; + default_branch?: string; + commit_count: number; + staged_count: number; + unstaged_count: number; + untracked_count: number; + ahead: number; + behind: number; + stash_count: number; + has_important_ignored_files: boolean; + important_ignored_files?: string[]; + branches: FinderBranchInfo[]; + all_branches_synced: boolean; + remotes: FinderRemoteInfo[]; + worktrees: FinderWorktreeInfo[]; + all_worktrees_synced: boolean; + read_error?: string; +} + +export interface FinderStatus { + version: number; + timestamp: string; + daemon_pid: number; + workspaces: FinderWorkspaceInfo[]; + custom_folders?: string[]; + repos: FinderRepoStatus[]; + org_folders?: OrgFolderInfo[]; + monitored_roots?: string[]; + boot_volume_aliases?: string[]; +} + +export interface StatusSnapshot { + status_path: string; + updated_at: string | null; + stale: boolean; + status: FinderStatus | null; +} + +export interface ExtensionStatus { + installed: boolean; + enabled: boolean; +} + +export type ProgressEvent = + | { type: 'discovery_orgs_discovered'; count: number } + | { type: 'discovery_org_started'; org_name: string } + | { type: 'discovery_org_complete'; org_name: string; repo_count: number } + | { type: 'discovery_personal_repos_started' } + | { type: 'discovery_personal_repos_complete'; count: number } + | { type: 'discovery_error'; message: string } + | { type: 'clone_started'; repo_name: string; index: number; total: number } + | { type: 'clone_completed'; repo_name: string; index: number; total: number } + | { + type: 'clone_failed'; + repo_name: string; + error: string; + index: number; + total: number; + } + | { + type: 'clone_skipped'; + repo_name: string; + reason: string; + index: number; + total: number; + } + | { + type: 'sync_started'; + repo_name: string; + path: string; + index: number; + total: number; + } + | { + type: 'sync_fetched'; + repo_name: string; + updated: boolean; + new_commits: number | null; + index: number; + total: number; + } + | { + type: 'sync_pulled'; + repo_name: string; + success: boolean; + updated: boolean; + fast_forward: boolean; + error: string | null; + index: number; + total: number; + } + | { + type: 'sync_failed'; + repo_name: string; + error: string; + index: number; + total: number; + } + | { + type: 'sync_skipped'; + repo_name: string; + reason: string; + index: number; + total: number; + }; + +export interface SyncProgressPayload { + workspace_id: string; + event: ProgressEvent; +} + +export interface SyncProgressState { + workspaceId: string; + message: string; + completed: number; + total: number | null; + failed: number; + skipped: number; +} diff --git a/crates/git-same-app/ui/src/lib/utils.ts b/crates/git-same-app/ui/src/lib/utils.ts new file mode 100644 index 0000000..0a76345 --- /dev/null +++ b/crates/git-same-app/ui/src/lib/utils.ts @@ -0,0 +1,78 @@ +import type { Badge, FinderRepoStatus } from './types'; + +export function summarize(items: FinderRepoStatus[]) { + return items.reduce( + (acc, repo) => { + acc.total += 1; + acc[repo.badge] += 1; + return acc; + }, + { total: 0, green: 0, blue: 0, orange: 0, red: 0, gray: 0 } as Record< + Badge | 'total', + number + >, + ); +} + +export function badgeLabel(badge: Badge): string { + return { + green: 'Synced', + blue: 'Local config', + orange: 'Branches', + red: 'Local work', + gray: 'Pending', + }[badge]; +} + +export function repoName(path: string): string { + return path.split('/').filter(Boolean).at(-1) ?? path; +} + +export function folderName(path: string): string { + return repoName(path) || path; +} + +export function parentPath(path: string): string { + const parts = path.split('/').filter(Boolean); + if (parts.length <= 1) return path; + return `${path.startsWith('/') ? '/' : ''}${parts.slice(0, -1).join('/')}`; +} + +export function linesToList(value: string): string[] { + return value + .split(/\r?\n|,/) + .map((item) => item.trim()) + .filter(Boolean); +} + +export function listToLines(value: string[] | null | undefined): string { + return (value ?? []).join('\n'); +} + +export function formatCount(value: number, singular: string, plural = `${singular}s`) { + return `${value} ${value === 1 ? singular : plural}`; +} + +export function repoChangeCount(repo: FinderRepoStatus): number { + return repo.staged_count + repo.unstaged_count + repo.untracked_count; +} + +export function isHighRiskRepo(repo: FinderRepoStatus): boolean { + return repo.badge === 'red' || Boolean(repo.read_error); +} + +export function relativeTime(value: string | null | undefined): string { + if (!value) return 'Never'; + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) return value; + const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 48) return `${hours}h ago`; + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + }).format(timestamp); +} diff --git a/crates/git-same-app/ui/src/main.ts b/crates/git-same-app/ui/src/main.ts new file mode 100644 index 0000000..19f4e49 --- /dev/null +++ b/crates/git-same-app/ui/src/main.ts @@ -0,0 +1,9 @@ +import './styles/tokens.css'; +import { mount } from 'svelte'; +import App from './App.svelte'; + +const app = mount(App, { + target: document.getElementById('app')!, +}); + +export default app; diff --git a/crates/git-same-app/ui/src/routes/BadgeBrowser.svelte b/crates/git-same-app/ui/src/routes/BadgeBrowser.svelte new file mode 100644 index 0000000..1ae9532 --- /dev/null +++ b/crates/git-same-app/ui/src/routes/BadgeBrowser.svelte @@ -0,0 +1,440 @@ + + +
+
+ + + + +
+ +
+ + + + + +
+ +
+
+
+ Repository + Badge + Branch + Changes + Remote +
+ {#if filtered.length === 0} + + {:else} + {#each filtered as repo} + + {/each} + {/if} +
+ + {#if selectedRepo} + + {/if} +
+
+ + diff --git a/crates/git-same-app/ui/src/routes/Dashboard.svelte b/crates/git-same-app/ui/src/routes/Dashboard.svelte new file mode 100644 index 0000000..8dcaae2 --- /dev/null +++ b/crates/git-same-app/ui/src/routes/Dashboard.svelte @@ -0,0 +1,330 @@ + + +
+
+
+ + {#if monitorOk}{:else}{/if} + +
+ {monitorOk ? 'Monitor running' : 'Monitor needs attention'} +

Last scan {relativeTime($snapshot?.updated_at)}

+
+
+
+ + {#if extensionOk}{:else}{/if} + +
+ {extensionOk ? 'Finder extension enabled' : 'Finder extension not ready'} +

{$extensionStatus?.installed ? 'Installed' : 'Not installed'} · {$extensionStatus?.enabled ? 'Enabled' : 'Disabled'}

+
+
+
+ {$workspaces.length} + {formatCount($workspaces.length, 'workspace')} +
+
+ {counts.total} + {formatCount(counts.total, 'repo')} +
+
+ +
+
+

Badge Distribution

+

{counts.red + counts.orange} repos need review

+
+
+ + + + + +
+
+ +
+
+
+

Workspaces

+

{$currentWorkspace?.name ?? 'No workspace selected'}

+
+ {#if $workspaces.length === 0} + + {:else} +
+ {#each $workspaces as workspace} + {@const summary = workspaceCounts(workspace)} +
+ +
+ {workspace.name} + {workspace.root} +
+ 0 ? 'red' : summary.orange > 0 ? 'orange' : 'green'} count={summary.total} /> + +
+ {/each} +
+ {/if} +
+ +
+
+

Needs Attention

+

{formatCount(highRiskRepos.length, 'repo')}

+
+ {#if highRiskRepos.length === 0} + + {:else} +
+ {#each highRiskRepos as repo} +
+
+ {repoName(repo.path)} + {repo.org ?? repo.workspace ?? repo.path} +
+ + {repoChangeCount(repo)} changes + {repo.ahead} ahead / {repo.behind} behind +
+ {/each} +
+ {/if} +
+
+
+ + diff --git a/crates/git-same-app/ui/src/routes/FinderBadges.svelte b/crates/git-same-app/ui/src/routes/FinderBadges.svelte new file mode 100644 index 0000000..1322153 --- /dev/null +++ b/crates/git-same-app/ui/src/routes/FinderBadges.svelte @@ -0,0 +1,323 @@ + + +
+
+
+

Setup Checklist

+

{setupRows.filter((row) => row.passed).length} / {setupRows.length} ready

+
+
+ {#each setupRows as row} +
+ + {#if row.passed}{:else}{/if} + +
+ {row.label} + {row.detail} +
+ {#if row.action && !row.passed} + + {/if} +
+ {/each} +
+
+ +
+
+
+

Badge Legend

+

Finder folder overlays

+
+
+
Clean, synced, and safe to mirror.
+
Synced, but important ignored local files exist.
+
Main is clean, but another branch or worktree diverges.
+
Uncommitted work, untracked files, or unpushed commits.
+
Ambient repo pending deeper classification.
+
+
+ +
+
+

Status File

+

Monitor output

+
+
+
+
Path
+
{$snapshot?.status_path ?? 'Unavailable'}
+
+
+
Last update
+
{relativeTime($snapshot?.updated_at)}
+
+
+
Monitor PID
+
{status?.daemon_pid ?? 'Unavailable'}
+
+
+
Repos visible
+
{status?.repos.length ?? 0}
+
+
+
+
+ +
+
+

Monitored Roots

+

{roots.length} paths

+
+ {#if roots.length === 0} + + {:else} +
+ {#each roots as root} +
+ + {root} +
+ {/each} +
+ {/if} +
+ +
+ + Finder badges update from the monitor status file. The app never deletes repositories when changing badge settings. +
+
+ + diff --git a/crates/git-same-app/ui/src/routes/Requirements.svelte b/crates/git-same-app/ui/src/routes/Requirements.svelte new file mode 100644 index 0000000..3473a8c --- /dev/null +++ b/crates/git-same-app/ui/src/routes/Requirements.svelte @@ -0,0 +1,239 @@ + + +
+
+
+ {passed} + Passing checks +
+
0}> + {criticalFailures} + Critical failures +
+ +
+ +
+ {#if $requirements.length === 0} +
+ Run checks to inspect system requirements. +
+ {:else} + {#each $requirements as check} + {@const action = actionFor(check.name, check.message)} +
+ + {#if check.passed}{:else}{/if} + +
+ {check.name} + {check.message} + {#if check.suggestion && !check.passed} +

{check.suggestion}

+ {/if} +
+ + {check.critical ? 'Critical' : 'Optional'} + + {#if action && !check.passed} + + {/if} +
+ {/each} + {/if} +
+
+ + diff --git a/crates/git-same-app/ui/src/routes/Settings.svelte b/crates/git-same-app/ui/src/routes/Settings.svelte new file mode 100644 index 0000000..ff40d47 --- /dev/null +++ b/crates/git-same-app/ui/src/routes/Settings.svelte @@ -0,0 +1,382 @@ + + +{#if !$appConfig} + + + +{:else if !$appConfig.exists} + + + +{:else} +
+
+
+

Global Config

+

{$appConfig.config_path}

+
+
+ + +
+
+ +
+

Sync Defaults

+ + + + + + +
+ +
+

Clone Defaults

+ + + +
+ +
+

Provider Filters

+ + + + +
+ +
+

Monitor

+ +

Restart the monitor on the Requirements screen for changes to take effect.

+
+ +
+

Finder Ambient Badges

+ + + + +
+
+{/if} + + diff --git a/crates/git-same-app/ui/src/routes/Workspace.svelte b/crates/git-same-app/ui/src/routes/Workspace.svelte new file mode 100644 index 0000000..b9b056d --- /dev/null +++ b/crates/git-same-app/ui/src/routes/Workspace.svelte @@ -0,0 +1,642 @@ + + +
+ + + {#if !$currentWorkspace} + + + + {:else} +
+
+ {formatCount(remoteRepos.length, 'GitHub repo')} + {sourceText} +
+
+ {formatCount(localRepos.length, 'local repo')} + {$currentWorkspace.root} +
+
+ {missingCount} + Missing locally +
+
+ {localOnlyCount} + Local only +
+
+ + {#if $workspaceStructure?.error} +
{$workspaceStructure.error}
+ {/if} + +
+
+
+

Repositories

+

+ {$workspaceStructure?.host ?? 'github.com'} · {matchedCount} paired · {$currentWorkspace.root} +

+
+ +
+ + {#if $workspaceStructureLoading && remoteRepos.length === 0 && localRepos.length === 0} +
Loading workspace…
+ {:else if pairedGroups.length === 0} + + {:else} +
+
+ + + GitHub + + + + + Local + +
+ + {#if visibleGroups.length === 0} +
No repositories match “{filter}”.
+ {:else} + {#each visibleGroups as group (group.owner)} +
+ {group.owner} + {formatCount(group.entries.length, 'repo')} +
+ + {#each group.entries as entry, i (entryKey(entry))} + {@const status = entryStatus(entry)} +
+ {#if entry.remote} + + {entry.remote.name} + + + {:else} + not on GitHub + {/if} + + + + {#if entry.local} + {repoName(entry.local.path)} + + {:else} + missing locally + + {/if} +
+ {/each} + {/each} + {/if} +
+ {/if} +
+ {/if} +
+ + diff --git a/crates/git-same-app/ui/src/routes/WorkspaceScreen.svelte b/crates/git-same-app/ui/src/routes/WorkspaceScreen.svelte new file mode 100644 index 0000000..07e11d0 --- /dev/null +++ b/crates/git-same-app/ui/src/routes/WorkspaceScreen.svelte @@ -0,0 +1,606 @@ + + +
+ + + {#if loading} + + {:else} +
+
+
+

{workspaceId ? 'Workspace Details' : 'Create Workspace'}

+

{workspaceId ? configPath : 'A new .git-same/config.toml will be created inside the selected folder.'}

+
+
+ {#if workspaceId} + + {/if} + +
+
+ +
+

Location

+ + + +
+ +
+

Provider

+ + + + +
+ +
+
+ +
+

Repository Selection

+ + + + + + + +
+ +
+

Overrides

+ + + + + + {#if useCloneOverride} + + + + {/if} +
+
+ {/if} +
+ + diff --git a/crates/git-same-app/ui/src/routes/router.ts b/crates/git-same-app/ui/src/routes/router.ts new file mode 100644 index 0000000..375f158 --- /dev/null +++ b/crates/git-same-app/ui/src/routes/router.ts @@ -0,0 +1,20 @@ +import type { RouteDefinition } from 'svelte-spa-router'; +import BadgeBrowser from './BadgeBrowser.svelte'; +import Dashboard from './Dashboard.svelte'; +import FinderBadges from './FinderBadges.svelte'; +import Requirements from './Requirements.svelte'; +import Settings from './Settings.svelte'; +import Workspace from './Workspace.svelte'; +import WorkspaceScreen from './WorkspaceScreen.svelte'; + +export const routes: RouteDefinition = { + '/': Dashboard, + '/dashboard': Dashboard, + '/finder-badges': FinderBadges, + '/badge-browser': BadgeBrowser, + '/workspace': Workspace, + '/workspace/screen': WorkspaceScreen, + '/settings': Settings, + '/requirements': Requirements, + '*': Dashboard, +}; diff --git a/crates/git-same-app/ui/src/stores/status.ts b/crates/git-same-app/ui/src/stores/status.ts new file mode 100644 index 0000000..9b3b686 --- /dev/null +++ b/crates/git-same-app/ui/src/stores/status.ts @@ -0,0 +1,311 @@ +import { derived, get, writable } from 'svelte/store'; +import { + checkRequirements, + deleteWorkspace, + ensureConfig, + installMonitorLaunchAgent, + listWorkspaces, + onStatusUpdated, + onSyncProgress, + readAppConfig, + readExtensionStatus, + readStatus, + readWorkspaceStructure, + restartMonitorLaunchAgent, + saveAppConfig, + setDefaultWorkspace, + startSync, +} from '../lib/tauri'; +import type { + AppConfigDto, + AppConfigInput, + ExtensionStatus, + ProgressEvent, + RequirementCheckDto, + StatusSnapshot, + SyncProgressPayload, + SyncProgressState, + WorkspaceInput, + WorkspaceStructureDto, + WorkspaceSummary, +} from '../lib/types'; +import { saveWorkspace as saveWorkspaceCommand } from '../lib/tauri'; + +export const NEW_WORKSPACE_ID = '__new_workspace__'; + +export const snapshot = writable(null); +export const workspaces = writable([]); +export const extensionStatus = writable(null); +export const appConfig = writable(null); +export const requirements = writable([]); +export const workspaceStructure = writable(null); +export const workspaceStructureLoading = writable(false); +export const selectedWorkspaceId = writable(''); +export const loading = writable(true); +export const requirementsLoading = writable(false); +export const syncingId = writable(''); +export const errorMessage = writable(''); +export const successMessage = writable(''); +export const syncProgress = writable(null); + +export const currentWorkspace = derived( + [workspaces, selectedWorkspaceId], + ([$workspaces, $selectedWorkspaceId]) => { + if ($selectedWorkspaceId === NEW_WORKSPACE_ID) return undefined; + if ($selectedWorkspaceId) { + return $workspaces.find((workspace) => workspace.id === $selectedWorkspaceId); + } + return $workspaces.find((workspace) => workspace.default) ?? $workspaces[0]; + }, +); + +export async function refresh(): Promise { + errorMessage.set(''); + const [workspaceList, status, ext, config] = await Promise.all([ + listWorkspaces().catch((err) => { + errorMessage.set(String(err)); + return [] as WorkspaceSummary[]; + }), + readStatus().catch((err) => { + errorMessage.set(String(err)); + return null; + }), + readExtensionStatus().catch(() => null), + readAppConfig().catch(() => null), + ]); + workspaces.set(workspaceList); + snapshot.set(status); + extensionStatus.set(ext); + appConfig.set(config); + reconcileSelectedWorkspace(workspaceList); +} + +export async function loadAppConfig(): Promise { + appConfig.set(await readAppConfig()); +} + +export async function createDefaultConfig(): Promise { + appConfig.set(await ensureConfig()); + await refresh(); +} + +export async function saveConfig(input: AppConfigInput): Promise { + errorMessage.set(''); + appConfig.set(await saveAppConfig(input)); + successMessage.set('Settings saved'); + await refresh(); +} + +export async function saveWorkspace(input: WorkspaceInput): Promise { + errorMessage.set(''); + const saved = await saveWorkspaceCommand(input); + selectedWorkspaceId.set(saved.id); + successMessage.set('Workspace saved'); + await refresh(); + await loadCurrentWorkspaceStructure(); + return saved.id; +} + +export async function removeWorkspace(workspaceId: string): Promise { + errorMessage.set(''); + const next = await deleteWorkspace(workspaceId); + workspaces.set(next); + selectedWorkspaceId.set(next.find((workspace) => workspace.default)?.id ?? next[0]?.id ?? ''); + successMessage.set('Workspace metadata removed'); + await refresh(); + await loadCurrentWorkspaceStructure(); +} + +export async function updateDefaultWorkspace(workspaceId: string | null): Promise { + errorMessage.set(''); + workspaces.set(await setDefaultWorkspace(workspaceId)); + await refresh(); +} + +export async function loadRequirements(): Promise { + requirementsLoading.set(true); + errorMessage.set(''); + try { + requirements.set(await checkRequirements()); + } catch (err) { + errorMessage.set(String(err)); + } finally { + requirementsLoading.set(false); + } +} + +export async function installMonitor(): Promise { + requirementsLoading.set(true); + errorMessage.set(''); + try { + await installMonitorLaunchAgent(); + successMessage.set('Monitor LaunchAgent installed'); + await Promise.all([refresh(), loadRequirements()]); + } catch (err) { + errorMessage.set(String(err)); + } finally { + requirementsLoading.set(false); + } +} + +export async function restartMonitor(): Promise { + requirementsLoading.set(true); + errorMessage.set(''); + try { + await restartMonitorLaunchAgent(); + successMessage.set('Monitor LaunchAgent restarted'); + await Promise.all([refresh(), loadRequirements()]); + } catch (err) { + errorMessage.set(String(err)); + } finally { + requirementsLoading.set(false); + } +} + +export async function startSyncCurrent(): Promise { + const workspace = get(currentWorkspace); + if (!workspace) return; + syncingId.set(workspace.id); + errorMessage.set(''); + syncProgress.set({ + workspaceId: workspace.id, + message: 'Starting sync', + completed: 0, + total: null, + failed: 0, + skipped: 0, + }); + try { + const next = await startSync(workspace.id); + snapshot.set(next); + await refresh(); + await loadCurrentWorkspaceStructure(); + } catch (err) { + errorMessage.set(String(err)); + } finally { + syncingId.set(''); + window.setTimeout(() => { + syncProgress.update((current) => + current?.workspaceId === workspace.id ? null : current, + ); + }, 1200); + } +} + +export async function loadCurrentWorkspaceStructure(): Promise { + const workspace = get(currentWorkspace); + if (!workspace) { + workspaceStructure.set(null); + return; + } + workspaceStructureLoading.set(true); + try { + workspaceStructure.set(await readWorkspaceStructure(workspace.id)); + } catch (err) { + errorMessage.set(String(err)); + workspaceStructure.set(null); + } finally { + workspaceStructureLoading.set(false); + } +} + +export async function subscribePush(): Promise<() => void> { + const unsubscribeStatus = await onStatusUpdated((next) => { + snapshot.set(next); + }); + const unsubscribeProgress = await onSyncProgress((payload) => { + syncProgress.update((current) => reduceSyncProgress(current, payload)); + }); + return () => { + unsubscribeStatus(); + unsubscribeProgress(); + }; +} + +function reconcileSelectedWorkspace(workspaceList: WorkspaceSummary[]) { + const selected = get(selectedWorkspaceId); + if (selected === NEW_WORKSPACE_ID) return; + if (selected && workspaceList.some((workspace) => workspace.id === selected)) return; + selectedWorkspaceId.set( + workspaceList.find((workspace) => workspace.default)?.id ?? workspaceList[0]?.id ?? '', + ); +} + +function reduceSyncProgress( + current: SyncProgressState | null, + payload: SyncProgressPayload, +): SyncProgressState { + const event = payload.event; + const next: SyncProgressState = + current?.workspaceId === payload.workspace_id + ? { ...current } + : { + workspaceId: payload.workspace_id, + message: 'Starting sync', + completed: 0, + total: null, + failed: 0, + skipped: 0, + }; + + next.message = progressMessage(event); + next.total = progressTotal(event) ?? next.total; + next.completed = Math.max(next.completed, progressCompleted(event)); + if (isFailure(event)) next.failed += 1; + if (isSkip(event)) next.skipped += 1; + return next; +} + +function progressMessage(event: ProgressEvent): string { + switch (event.type) { + case 'discovery_orgs_discovered': + return `Found ${event.count} organizations`; + case 'discovery_org_started': + return `Discovering ${event.org_name}`; + case 'discovery_org_complete': + return `Found ${event.repo_count} repos in ${event.org_name}`; + case 'discovery_personal_repos_started': + return 'Discovering personal repos'; + case 'discovery_personal_repos_complete': + return `Found ${event.count} personal repos`; + case 'discovery_error': + return event.message; + case 'clone_started': + return `Cloning ${event.repo_name}`; + case 'clone_completed': + return `Cloned ${event.repo_name}`; + case 'clone_failed': + return `Clone failed: ${event.repo_name}`; + case 'clone_skipped': + return `Skipped clone: ${event.repo_name}`; + case 'sync_started': + return `Syncing ${event.repo_name}`; + case 'sync_fetched': + return event.updated + ? `Fetched ${event.repo_name}` + : `Checked ${event.repo_name}`; + case 'sync_pulled': + return `Pulled ${event.repo_name}`; + case 'sync_failed': + return `Sync failed: ${event.repo_name}`; + case 'sync_skipped': + return `Skipped sync: ${event.repo_name}`; + } +} + +function progressTotal(event: ProgressEvent): number | null { + return 'total' in event ? event.total : null; +} + +function progressCompleted(event: ProgressEvent): number { + if (!('index' in event)) return 0; + return event.type.endsWith('started') ? 0 : event.index + 1; +} + +function isFailure(event: ProgressEvent): boolean { + return event.type === 'clone_failed' || event.type === 'sync_failed'; +} + +function isSkip(event: ProgressEvent): boolean { + return event.type === 'clone_skipped' || event.type === 'sync_skipped'; +} diff --git a/crates/git-same-app/ui/src/styles/tokens.css b/crates/git-same-app/ui/src/styles/tokens.css new file mode 100644 index 0000000..f8622fb --- /dev/null +++ b/crates/git-same-app/ui/src/styles/tokens.css @@ -0,0 +1,81 @@ +:root { + color-scheme: light dark; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #f5f6f8; + color: #1d232b; + font-synthesis: none; + text-rendering: optimizeLegibility; + --bg: #f5f6f8; + --sidebar: #eef1f5; + --panel: #ffffff; + --panel-alt: #f1f3f6; + --hover: #e6ebf2; + --selected: #dce9fb; + --line: #d8dee8; + --line-strong: #c5ceda; + --text: #1d232b; + --muted: #66717f; + --accent: #246fc8; + --accent-strong: #165bb0; + --danger: #c23b3b; + --warning: #b66a12; + --ok: #2f7d50; + --blue: #246fc8; + --shadow: 0 10px 24px rgba(30, 40, 54, 0.08); +} + +@media (prefers-color-scheme: dark) { + :root { + background: #111418; + color: #eef2f6; + --bg: #111418; + --sidebar: #171b21; + --panel: #1d2229; + --panel-alt: #252b34; + --hover: #2b3340; + --selected: #19375f; + --line: #333b47; + --line-strong: #475160; + --text: #eef2f6; + --muted: #a4adba; + --accent: #68a8ff; + --accent-strong: #94c2ff; + --danger: #ff817d; + --warning: #e7a646; + --ok: #70c78f; + --blue: #84b8f5; + --shadow: 0 12px 28px rgba(0, 0, 0, 0.24); + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: var(--bg); +} + +button { + font: inherit; +} + +input, +select, +textarea { + font: inherit; +} + +.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/crates/git-same-app/ui/src/vite-env.d.ts b/crates/git-same-app/ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/crates/git-same-app/ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/crates/git-same-app/ui/tsconfig.json b/crates/git-same-app/ui/tsconfig.json new file mode 100644 index 0000000..64ff596 --- /dev/null +++ b/crates/git-same-app/ui/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "ES2022", + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.svelte"] +} diff --git a/crates/git-same-app/ui/tsconfig.node.json b/crates/git-same-app/ui/tsconfig.node.json new file mode 100644 index 0000000..3b9e402 --- /dev/null +++ b/crates/git-same-app/ui/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "target": "ES2022" + }, + "include": ["vite.config.ts"] +} diff --git a/crates/git-same-app/ui/vite.config.ts b/crates/git-same-app/ui/vite.config.ts new file mode 100644 index 0000000..11672c3 --- /dev/null +++ b/crates/git-same-app/ui/vite.config.ts @@ -0,0 +1,12 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [svelte()], + clearScreen: false, + server: { + host: '127.0.0.1', + port: 1420, + strictPort: true, + }, +}); diff --git a/crates/git-same-cli/Cargo.toml b/crates/git-same-cli/Cargo.toml new file mode 100644 index 0000000..75ac847 --- /dev/null +++ b/crates/git-same-cli/Cargo.toml @@ -0,0 +1,63 @@ +[package] +name = "git-same" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +keywords = ["git", "github", "cli", "clone", "sync"] +categories = ["command-line-utilities"] +description = "Mirror GitHub structure /orgs/repos/ to local file system." + +# Naming notes: +# - Directory on disk: crates/git-same-cli/ (kept distinct from the engine +# crate's directory name for filesystem clarity) +# - Package on crates.io: git-same (cargo install git-same; pre-3.2 install +# path preserved with no shim) +# - Library name (auto-derived from package): git_same +# - Binary produced: git-same (filesystem aliases: gisa, gitsa, gitsame) +# - Homebrew formula name: git-same-cli (independent of this; lives in +# zaai-com/homebrew-tap) + +[[bin]] +name = "git-same" +path = "src/main.rs" + +[[bin]] +name = "gen-completions" +path = "src/bin/gen_completions.rs" +required-features = ["release-tools"] + +[[bin]] +name = "gen-manpage" +path = "src/bin/gen_manpage.rs" +required-features = ["release-tools"] + +[features] +default = ["tui"] +tui = ["dep:ratatui", "dep:crossterm"] +release-tools = ["dep:clap_complete", "dep:clap_mangen"] + +[dependencies] +git-same-core = { path = "../git-same-core", version = "=3.1.0" } +clap = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +indicatif = { workspace = true } +console = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +shellexpand = { workspace = true } +chrono = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +ratatui = { workspace = true, optional = true } +crossterm = { workspace = true, optional = true } +clap_complete = { workspace = true, optional = true } +clap_mangen = { workspace = true, optional = true } + +[dev-dependencies] +git-same-core = { path = "../git-same-core", features = ["test-utils"] } +tempfile = { workspace = true } diff --git a/src/app/cli/mod.rs b/crates/git-same-cli/src/app/cli/mod.rs similarity index 100% rename from src/app/cli/mod.rs rename to crates/git-same-cli/src/app/cli/mod.rs diff --git a/src/app/mod.rs b/crates/git-same-cli/src/app/mod.rs similarity index 100% rename from src/app/mod.rs rename to crates/git-same-cli/src/app/mod.rs diff --git a/src/app/tui/mod.rs b/crates/git-same-cli/src/app/tui/mod.rs similarity index 100% rename from src/app/tui/mod.rs rename to crates/git-same-cli/src/app/tui/mod.rs diff --git a/src/banner.rs b/crates/git-same-cli/src/banner.rs similarity index 100% rename from src/banner.rs rename to crates/git-same-cli/src/banner.rs diff --git a/src/banner_tests.rs b/crates/git-same-cli/src/banner_tests.rs similarity index 100% rename from src/banner_tests.rs rename to crates/git-same-cli/src/banner_tests.rs diff --git a/src/bin/gen_completions.rs b/crates/git-same-cli/src/bin/gen_completions.rs similarity index 100% rename from src/bin/gen_completions.rs rename to crates/git-same-cli/src/bin/gen_completions.rs diff --git a/src/bin/gen_manpage.rs b/crates/git-same-cli/src/bin/gen_manpage.rs similarity index 100% rename from src/bin/gen_manpage.rs rename to crates/git-same-cli/src/bin/gen_manpage.rs diff --git a/src/cli.rs b/crates/git-same-cli/src/cli.rs similarity index 83% rename from src/cli.rs rename to crates/git-same-cli/src/cli.rs index c124d64..a1a463e 100644 --- a/src/cli.rs +++ b/crates/git-same-cli/src/cli.rs @@ -150,6 +150,23 @@ Examples: )] Reset(ResetArgs), + /// Monitor workspace repos for the macOS Finder extension (and similar) + #[command( + alias = "daemon", + long_about = "Run the git-same monitor: a long-running process that scans \ + workspace repositories, computes Finder badge colors, and writes status \ + to ~/.config/git-same/finder/status.json. It also listens on a Unix socket \ + for refresh requests from the macOS Finder Sync extension. The `daemon` \ + alias is kept for backward compatibility and may be removed in 4.0.", + after_help = "\ +Examples: + gisa monitor Start the monitor in the foreground + gisa monitor --interval 60 Poll every 60 seconds + gisa monitor --status Check if the monitor is running + gisa monitor --stop Stop a running monitor" + )] + Monitor(MonitorArgs), + /// Scan a directory tree for unregistered workspaces (.git-same/ folders) #[command( long_about = "Walk a directory tree looking for .git-same/ marker folders \ @@ -165,6 +182,19 @@ Examples: gisa scan ~/projects --register Auto-register found workspaces" )] Scan(ScanArgs), + + /// Ask the running monitor to refresh status.json immediately + #[command( + long_about = "Send a refresh request to the background monitor so it \ + rewrites ~/.config/git-same/finder/status.json right now. Useful \ + after manually deleting a repo, or when debugging Finder badges. \ + Fails with a clear error if the monitor is not running.", + after_help = "\ +Examples: + gisa refresh Refresh everything the monitor knows about + gisa refresh --path ~/work/org Refresh a single folder" + )] + Refresh(RefreshArgs), } /// Arguments for the init command @@ -290,6 +320,34 @@ pub struct ResetArgs { pub force: bool, } +/// Arguments for the monitor command +#[derive(Args, Debug)] +pub struct MonitorArgs { + /// Run in foreground (legacy flag; the monitor always runs in the foreground today) + #[arg(long)] + pub foreground: bool, + + /// Polling interval in seconds (overrides the value from config.toml) + #[arg(long)] + pub interval: Option, + + /// Stop a running monitor + #[arg(long)] + pub stop: bool, + + /// Show monitor status (running, PID, last scan) + #[arg(long)] + pub status: bool, +} + +/// Arguments for the refresh command +#[derive(Args, Debug)] +pub struct RefreshArgs { + /// Refresh a specific folder instead of everything the monitor watches + #[arg(long)] + pub path: Option, +} + /// Arguments for the scan command #[derive(Args, Debug)] pub struct ScanArgs { diff --git a/src/cli_tests.rs b/crates/git-same-cli/src/cli_tests.rs similarity index 100% rename from src/cli_tests.rs rename to crates/git-same-cli/src/cli_tests.rs diff --git a/src/commands/init.rs b/crates/git-same-cli/src/commands/init.rs similarity index 93% rename from src/commands/init.rs rename to crates/git-same-cli/src/commands/init.rs index 0b99112..89ce2d7 100644 --- a/src/commands/init.rs +++ b/crates/git-same-cli/src/commands/init.rs @@ -2,11 +2,11 @@ //! //! Checks system requirements and writes the global configuration file. -use crate::checks::{self, CheckResult}; use crate::cli::InitArgs; -use crate::config::Config; -use crate::errors::{AppError, Result}; -use crate::output::Output; +use git_same_core::checks::{self, CheckResult}; +use git_same_core::config::Config; +use git_same_core::errors::{AppError, Result}; +use git_same_core::output::Output; /// Initialize gisa configuration. pub async fn run(args: &InitArgs, output: &Output) -> Result<()> { diff --git a/src/commands/init_tests.rs b/crates/git-same-cli/src/commands/init_tests.rs similarity index 97% rename from src/commands/init_tests.rs rename to crates/git-same-cli/src/commands/init_tests.rs index 69494d6..4cae2d7 100644 --- a/src/commands/init_tests.rs +++ b/crates/git-same-cli/src/commands/init_tests.rs @@ -3,7 +3,7 @@ use crate::cli::InitArgs; use tempfile::TempDir; fn quiet_output() -> Output { - Output::new(crate::output::Verbosity::Quiet, false) + Output::new(git_same_core::output::Verbosity::Quiet, false) } #[tokio::test] diff --git a/src/commands/mod.rs b/crates/git-same-cli/src/commands/mod.rs similarity index 88% rename from src/commands/mod.rs rename to crates/git-same-cli/src/commands/mod.rs index 1ae8e43..971d066 100644 --- a/src/commands/mod.rs +++ b/crates/git-same-cli/src/commands/mod.rs @@ -4,6 +4,8 @@ //! separated from `main.rs` so the entrypoint stays focused on bootstrapping. pub mod init; +pub mod monitor; +pub mod refresh; pub mod reset; pub mod scan; #[cfg(feature = "tui")] @@ -18,9 +20,9 @@ pub use status::run as run_status; pub use sync_cmd::run as run_sync_cmd; use crate::cli::Command; -use crate::config::Config; -use crate::errors::{AppError, Result}; -use crate::output::Output; +use git_same_core::config::Config; +use git_same_core::errors::{AppError, Result}; +use git_same_core::output::Output; use std::path::Path; pub(crate) use support::{ensure_base_path, warn_if_concurrency_capped}; @@ -59,9 +61,11 @@ pub async fn run_command( Command::Init(_) | Command::Reset(_) | Command::Scan(_) => unreachable!(), #[cfg(feature = "tui")] Command::Setup(_) => unreachable!(), + Command::Monitor(args) => monitor::run(args, &config, output).await, Command::Sync(args) => run_sync_cmd(args, &config, output).await, Command::Status(args) => run_status(args, &config, output).await, Command::Workspace(args) => workspace::run(args, &config, output), + Command::Refresh(args) => refresh::run(args, &config, output).await, } } diff --git a/crates/git-same-cli/src/commands/monitor.rs b/crates/git-same-cli/src/commands/monitor.rs new file mode 100644 index 0000000..d81996b --- /dev/null +++ b/crates/git-same-cli/src/commands/monitor.rs @@ -0,0 +1,171 @@ +//! `gisa monitor`: start, stop, or query the long-running monitor process. +//! +//! The actual run-loop lives in `git_same_core::monitor`. This file is the +//! CLI surface only: parse args, handle `--status` / `--stop` locally, build +//! the shutdown future from `ctrl_c` + SIGTERM, and call into core. +//! +//! `--status` and `--stop` stay in the CLI because they don't need the loop: +//! they just read the status file or send a kill signal to the recorded PID. + +use crate::cli::MonitorArgs; +use git_same_core::config::Config; +use git_same_core::errors::Result; +use git_same_core::ipc::{IpcConfig, StatusFileWriter}; +use git_same_core::monitor; +use git_same_core::output::Output; +use std::time::Duration; +use tracing::info; + +/// Run the `monitor` subcommand. +pub async fn run(args: &MonitorArgs, config: &Config, output: &Output) -> Result<()> { + let ipc_config = IpcConfig::default_path()?; + ipc_config.ensure_dir()?; + + if args.status { + return show_status(&ipc_config, output); + } + if args.stop { + return stop_monitor(&ipc_config, output); + } + + info!("Starting git-same monitor"); + + let interval_secs = resolve_interval_secs(args.interval, config.monitor.fullscan_interval_secs); + let opts = monitor::Options { + interval: Duration::from_secs(interval_secs), + ipc_config, + }; + + monitor::run(config, output, opts, shutdown_signal()).await +} + +/// Resolve the effective polling interval: an explicit `--interval` flag wins, +/// otherwise fall back to the value from `config.toml`. +fn resolve_interval_secs(cli_flag: Option, config_value: u64) -> u64 { + cli_flag.unwrap_or(config_value) +} + +/// Resolve when the user hits ctrl-c (SIGINT) or `gisa monitor --stop` +/// sends SIGTERM. Used as the shutdown future for the monitor loop. +async fn shutdown_signal() { + #[cfg(unix)] + { + let mut sigterm = + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(s) => s, + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = sigterm.recv() => {}, + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + +/// Show monitor status. +/// +/// User-facing diagnostic output is printed directly so it is not suppressed +/// by the default Quiet verbosity: `--status` must always answer. +fn show_status(ipc_config: &IpcConfig, _output: &Output) -> Result<()> { + let status_path = ipc_config.status_file_path(); + if !status_path.exists() { + println!("Monitor is not running (no status file found)"); + return Ok(()); + } + + let writer = StatusFileWriter::new(status_path); + match writer.read() { + Ok(status) => { + let pid = status.daemon_pid; + if is_process_alive(pid) { + println!("Monitor is running (PID: {})", pid); + } else { + println!("Monitor is not running (stale PID: {})", pid); + } + println!("Last scan: {}", status.timestamp); + println!("Repos monitored: {}", status.repos.len()); + println!( + "Workspaces: {}", + status + .workspaces + .iter() + .map(|w| w.name.as_str()) + .collect::>() + .join(", ") + ); + } + Err(e) => { + eprintln!("Could not read status file: {}", e); + } + } + Ok(()) +} + +/// Stop a running monitor process. +#[cfg(unix)] +fn stop_monitor(ipc_config: &IpcConfig, _output: &Output) -> Result<()> { + let status_path = ipc_config.status_file_path(); + if !status_path.exists() { + println!("No monitor is running"); + return Ok(()); + } + + let writer = StatusFileWriter::new(status_path); + match writer.read() { + Ok(status) => { + let pid = status.daemon_pid; + if is_process_alive(pid) { + let _ = std::process::Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + println!("Sent stop signal to monitor (PID: {})", pid); + } else { + println!("Monitor is not running (stale status file)"); + } + } + Err(_) => { + println!("Could not read monitor status"); + } + } + Ok(()) +} + +/// Non-Unix fallback: the monitor relies on Unix sockets and POSIX signals, +/// so there is no running process to stop on these platforms. +#[cfg(not(unix))] +fn stop_monitor(_ipc_config: &IpcConfig, _output: &Output) -> Result<()> { + println!("Monitor stop is not supported on this platform"); + Ok(()) +} + +/// Check if a process with the given PID is alive via `kill -0`. +#[cfg(unix)] +fn is_process_alive(pid: u32) -> bool { + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Non-Unix fallback: without POSIX signals we can't probe liveness, so assume +/// the recorded PID is alive rather than report a false negative. +#[cfg(not(unix))] +fn is_process_alive(_pid: u32) -> bool { + true +} + +#[cfg(test)] +#[path = "monitor_tests.rs"] +mod tests; diff --git a/crates/git-same-cli/src/commands/monitor_tests.rs b/crates/git-same-cli/src/commands/monitor_tests.rs new file mode 100644 index 0000000..a4b8222 --- /dev/null +++ b/crates/git-same-cli/src/commands/monitor_tests.rs @@ -0,0 +1,23 @@ +use super::*; + +#[test] +fn test_is_process_alive_self() { + let pid = std::process::id(); + assert!(is_process_alive(pid)); +} + +#[test] +fn test_is_process_alive_nonexistent() { + // PID 99999 is very unlikely to exist + assert!(!is_process_alive(99999)); +} + +#[test] +fn cli_flag_overrides_config_interval() { + assert_eq!(resolve_interval_secs(Some(10), 30), 10); +} + +#[test] +fn config_interval_used_when_flag_absent() { + assert_eq!(resolve_interval_secs(None, 90), 90); +} diff --git a/crates/git-same-cli/src/commands/refresh.rs b/crates/git-same-cli/src/commands/refresh.rs new file mode 100644 index 0000000..a41d381 --- /dev/null +++ b/crates/git-same-cli/src/commands/refresh.rs @@ -0,0 +1,60 @@ +//! Refresh command handler. +//! +//! User-facing wrapper around the monitor's REFRESH / REFRESH_ALL socket commands. +//! Forces an immediate status.json rewrite so the Finder extension picks up +//! on-disk changes without waiting for the monitor's next poll. + +use crate::cli::RefreshArgs; +use git_same_core::config::Config; +use git_same_core::errors::Result; +use git_same_core::output::Output; + +/// Ask the running monitor to refresh its status cache. +pub async fn run(args: &RefreshArgs, _config: &Config, output: &Output) -> Result<()> { + run_impl(args, output).await +} + +#[cfg(unix)] +async fn run_impl(args: &RefreshArgs, output: &Output) -> Result<()> { + use git_same_core::ipc::IpcConfig; + + let cfg = IpcConfig::default_path()?; + run_with_socket_path(args, output, cfg.socket_path()).await +} + +#[cfg(unix)] +async fn run_with_socket_path( + args: &RefreshArgs, + output: &Output, + socket_path: std::path::PathBuf, +) -> Result<()> { + use git_same_core::ipc::UnixSocketClient; + + let client = UnixSocketClient::new(socket_path); + + let response = match args.path.as_deref() { + Some(p) => client.refresh(p).await, + None => client.refresh_all().await, + }; + + match response { + Ok(_) => { + output.success("Monitor refreshed"); + Ok(()) + } + Err(e) => { + output.error("Monitor not reachable. Start it with `gisa monitor`."); + Err(e) + } + } +} + +#[cfg(not(unix))] +async fn run_impl(_args: &RefreshArgs, output: &Output) -> Result<()> { + output.warn("`gisa refresh` is unix-only for now (no monitor socket on this platform)."); + Ok(()) +} + +#[cfg(test)] +#[path = "refresh_tests.rs"] +mod tests; diff --git a/crates/git-same-cli/src/commands/refresh_tests.rs b/crates/git-same-cli/src/commands/refresh_tests.rs new file mode 100644 index 0000000..a78abb2 --- /dev/null +++ b/crates/git-same-cli/src/commands/refresh_tests.rs @@ -0,0 +1,25 @@ +use super::*; +use crate::cli::RefreshArgs; +use git_same_core::output::{Output, Verbosity}; + +#[tokio::test] +async fn refresh_with_no_monitor_returns_error_on_unix() { + // With no monitor listening on the socket, the command must surface an + // error (unlike the post-sync/post-reset nudges, which stay silent). + let args = RefreshArgs { path: None }; + let output = Output::new(Verbosity::Quiet, false); + + #[cfg(unix)] + { + let temp = tempfile::tempdir().expect("tempdir"); + let socket_path = temp.path().join("missing-monitor.sock"); + let res = run_with_socket_path(&args, &output, socket_path).await; + assert!(res.is_err(), "expected error when monitor is not running"); + } + #[cfg(not(unix))] + { + let cfg = Config::default(); + let res = run(&args, &cfg, &output).await; + assert!(res.is_ok(), "non-unix fallback should succeed"); + } +} diff --git a/src/commands/reset.rs b/crates/git-same-cli/src/commands/reset.rs similarity index 90% rename from src/commands/reset.rs rename to crates/git-same-cli/src/commands/reset.rs index b478d9b..4e8971f 100644 --- a/src/commands/reset.rs +++ b/crates/git-same-cli/src/commands/reset.rs @@ -4,10 +4,10 @@ //! Supports interactive scope selection or `--force` for scripting. use crate::cli::ResetArgs; -use crate::config::{Config, WorkspaceConfig, WorkspaceManager}; -use crate::errors::{AppError, Result}; -use crate::output::Output; use chrono::{DateTime, Utc}; +use git_same_core::config::{Config, WorkspaceConfig, WorkspaceManager}; +use git_same_core::errors::{AppError, Result}; +use git_same_core::output::Output; use std::io::{self, BufRead, Write}; use std::path::PathBuf; @@ -58,6 +58,7 @@ pub async fn run(args: &ResetArgs, output: &Output) -> Result<()> { if args.force { display_detailed_targets(&ResetScope::Everything, &target, output); execute_reset(&ResetScope::Everything, &target, output)?; + nudge_daemon_refresh().await; return Ok(()); } @@ -71,9 +72,25 @@ pub async fn run(args: &ResetArgs, output: &Output) -> Result<()> { } execute_reset(&scope, &target, output)?; + nudge_daemon_refresh().await; Ok(()) } +#[cfg(unix)] +async fn nudge_daemon_refresh() { + use git_same_core::ipc::{IpcConfig, UnixSocketClient}; + let Ok(cfg) = IpcConfig::default_path() else { + return; + }; + let client = UnixSocketClient::new(cfg.socket_path()); + if let Err(e) = client.refresh_all().await { + tracing::debug!(error = %e, "Monitor refresh nudge skipped"); + } +} + +#[cfg(not(unix))] +async fn nudge_daemon_refresh() {} + /// Discover what files and directories exist that could be removed. fn discover_targets() -> Result { let config_path = Config::default_path()?; @@ -153,7 +170,7 @@ fn display_detailed_targets(scope: &ResetScope, target: &ResetTarget, output: &O /// Display detail for a single workspace. fn display_workspace_detail(ws: &WorkspaceDetail, output: &Output) { - let path_display = crate::config::workspace::tilde_collapse_path(&ws.root_path); + let path_display = git_same_core::config::workspace::tilde_collapse_path(&ws.root_path); output.info(&format!(" Workspace at {}:", path_display)); if ws.orgs.is_empty() { @@ -238,7 +255,15 @@ fn execute_reset(scope: &ResetScope, target: &ResetTarget, output: &Output) -> R } fn remove_workspace_dir(ws: &WorkspaceDetail, output: &Output) -> bool { - let path_display = crate::config::workspace::tilde_collapse_path(&ws.root_path); + let path_display = git_same_core::config::workspace::tilde_collapse_path(&ws.root_path); + + // Strip the Git-Same custom folder icon before removing the workspace + // config. Reset only removes `.git-same/`, never the user's data + // directory, so without this the workspace root would keep showing the + // Git-Same logo in Finder forever — pointing at a workspace that no + // longer exists. Failures are logged but non-fatal. + git_same_core::macos::folder_icon::clear_or_log(&ws.root_path); + match std::fs::remove_dir_all(&ws.dot_dir) { Ok(()) => { // Also unregister from global config @@ -337,7 +362,7 @@ fn prompt_scope(target: &ResetTarget) -> Result { fn prompt_workspace(workspaces: &[WorkspaceDetail]) -> Result { eprintln!("\nSelect a workspace to delete:"); for (i, ws) in workspaces.iter().enumerate() { - let path_display = crate::config::workspace::tilde_collapse_path(&ws.root_path); + let path_display = git_same_core::config::workspace::tilde_collapse_path(&ws.root_path); let orgs = if ws.orgs.is_empty() { "all orgs".to_string() } else { diff --git a/src/commands/reset_tests.rs b/crates/git-same-cli/src/commands/reset_tests.rs similarity index 67% rename from src/commands/reset_tests.rs rename to crates/git-same-cli/src/commands/reset_tests.rs index a789aeb..2941107 100644 --- a/src/commands/reset_tests.rs +++ b/crates/git-same-cli/src/commands/reset_tests.rs @@ -76,7 +76,7 @@ fn test_display_workspace_detail_no_panic() { dot_dir: PathBuf::from("/tmp/test/.git-same"), cache_size: Some(12345), }; - let output = Output::new(crate::output::Verbosity::Quiet, false); + let output = Output::new(git_same_core::output::Verbosity::Quiet, false); display_workspace_detail(&ws, &output); } @@ -93,7 +93,7 @@ fn test_display_detailed_targets_everything() { cache_size: None, }], }; - let output = Output::new(crate::output::Verbosity::Quiet, false); + let output = Output::new(git_same_core::output::Verbosity::Quiet, false); display_detailed_targets(&ResetScope::Everything, &target, &output); } @@ -104,6 +104,40 @@ fn test_display_detailed_targets_config_only() { config_file: Some(PathBuf::from("/tmp/test/config.toml")), workspaces: Vec::new(), }; - let output = Output::new(crate::output::Verbosity::Quiet, false); + let output = Output::new(git_same_core::output::Verbosity::Quiet, false); display_detailed_targets(&ResetScope::ConfigOnly, &target, &output); } + +#[cfg(target_os = "macos")] +#[test] +fn test_remove_workspace_dir_clears_folder_icon() { + use git_same_core::macos::folder_icon; + + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().to_path_buf(); + + // Paint the workspace folder icon directly so the test doesn't depend on + // the full setup path. + folder_icon::set(&root, folder_icon::WORKSPACE_FOLDER_ICNS) + .expect("set icon on tempdir should succeed"); + assert!(folder_icon::is_set(&root), "icon was not set"); + + // dot_dir intentionally does not exist: remove_dir_all will fail and + // remove_workspace_dir will return false without ever touching the + // global registry — but only after clear_or_log has run, which is what + // we're verifying. + let ws = WorkspaceDetail { + root_path: root.clone(), + orgs: Vec::new(), + last_synced: None, + dot_dir: root.join(".git-same-does-not-exist"), + cache_size: None, + }; + let output = Output::new(git_same_core::output::Verbosity::Quiet, false); + let _ = remove_workspace_dir(&ws, &output); + + assert!( + !folder_icon::is_set(&root), + "remove_workspace_dir did not clear the workspace folder icon" + ); +} diff --git a/src/commands/scan.rs b/crates/git-same-cli/src/commands/scan.rs similarity index 96% rename from src/commands/scan.rs rename to crates/git-same-cli/src/commands/scan.rs index 62738ba..8ed9107 100644 --- a/src/commands/scan.rs +++ b/crates/git-same-cli/src/commands/scan.rs @@ -1,9 +1,9 @@ //! Scan command — find unregistered .git-same/ workspace folders. use crate::cli::ScanArgs; -use crate::config::{Config, WorkspaceStore}; -use crate::errors::{AppError, Result}; -use crate::output::Output; +use git_same_core::config::{Config, WorkspaceStore}; +use git_same_core::errors::{AppError, Result}; +use git_same_core::output::Output; use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -54,7 +54,7 @@ pub fn run(args: &ScanArgs, config_path: Option<&Path>, output: &Output) -> Resu let mut register_failures = Vec::new(); for ws_root in &found { let is_registered = registered.contains(ws_root); - let tilde = crate::config::workspace::tilde_collapse_path(ws_root); + let tilde = git_same_core::config::workspace::tilde_collapse_path(ws_root); if is_registered { output.plain(&format!(" [registered] {}", tilde)); } else { diff --git a/src/commands/scan_tests.rs b/crates/git-same-cli/src/commands/scan_tests.rs similarity index 92% rename from src/commands/scan_tests.rs rename to crates/git-same-cli/src/commands/scan_tests.rs index e7eab71..f2ea87e 100644 --- a/src/commands/scan_tests.rs +++ b/crates/git-same-cli/src/commands/scan_tests.rs @@ -87,17 +87,21 @@ fn run_register_with_custom_config_path_updates_registry() { .unwrap(); let custom_config_path = temp.path().join("custom-config.toml"); - std::fs::write(&custom_config_path, crate::config::Config::default_toml()).unwrap(); + std::fs::write( + &custom_config_path, + git_same_core::config::Config::default_toml(), + ) + .unwrap(); let args = crate::cli::ScanArgs { path: Some(scan_root), depth: 5, register: true, }; - let output = crate::output::Output::quiet(); + let output = git_same_core::output::Output::quiet(); run(&args, Some(&custom_config_path), &output).unwrap(); - let cfg = crate::config::Config::load_from(&custom_config_path).unwrap(); + let cfg = git_same_core::config::Config::load_from(&custom_config_path).unwrap(); assert_eq!(cfg.workspaces.len(), 1); let expected_suffix = std::path::Path::new("scan-root") .join("team") @@ -130,7 +134,7 @@ fn run_returns_error_when_custom_config_is_invalid() { depth: 5, register: false, }; - let output = crate::output::Output::quiet(); + let output = git_same_core::output::Output::quiet(); let err = run(&args, Some(&invalid_config_path), &output).unwrap_err(); assert!(err.to_string().contains("Failed to parse config")); } diff --git a/src/commands/setup.rs b/crates/git-same-cli/src/commands/setup.rs similarity index 89% rename from src/commands/setup.rs rename to crates/git-same-cli/src/commands/setup.rs index 77d0b27..6ff995e 100644 --- a/src/commands/setup.rs +++ b/crates/git-same-cli/src/commands/setup.rs @@ -5,9 +5,9 @@ #[cfg(feature = "tui")] use crate::cli::SetupArgs; #[cfg(feature = "tui")] -use crate::errors::Result; +use git_same_core::errors::Result; #[cfg(feature = "tui")] -use crate::output::Output; +use git_same_core::output::Output; /// Run the setup wizard. #[cfg(feature = "tui")] diff --git a/crates/git-same-cli/src/commands/status.rs b/crates/git-same-cli/src/commands/status.rs new file mode 100644 index 0000000..84f5f25 --- /dev/null +++ b/crates/git-same-cli/src/commands/status.rs @@ -0,0 +1,145 @@ +//! Status command handler. + +use crate::cli::StatusArgs; +use git_same_core::api::RepoScanService; +use git_same_core::config::{Config, WorkspaceManager}; +use git_same_core::errors::Result; +use git_same_core::git::ShellGit; +use git_same_core::output::{format_count, Output}; + +/// Show status of repositories. +pub async fn run(args: &StatusArgs, config: &Config, output: &Output) -> Result<()> { + let workspace = WorkspaceManager::resolve(args.workspace.as_deref(), config)?; + + // Ensure base path exists (offer to fix if user moved it) + super::ensure_base_path(&workspace, output)?; + + // Use the scan service to get full FinderRepoStatus for every repo + let git = ShellGit::new(); + let service = RepoScanService::new(&git, config); + let repos = service.scan_workspace(&workspace)?; + + if repos.is_empty() { + output.warn("No repositories found"); + return Ok(()); + } + + output.info(&format_count(repos.len(), "repositories found")); + + // Get status for each + let mut uncommitted_count = 0; + let mut behind_count = 0; + let mut error_count = 0; + + for repo in &repos { + // Apply org filter first so it suppresses output for both readable + // and unreadable repos consistently. + if !args.org.is_empty() { + let matches_org = repo + .org + .as_ref() + .map(|o| args.org.contains(o)) + .unwrap_or(false); + if !matches_org { + continue; + } + } + + // Repos that couldn't be read get tallied separately; they have + // zero counts and would otherwise appear as clean. + if let Some(err) = &repo.read_error { + error_count += 1; + output.verbose(&format!(" {} - error: {}", repo.path.display(), err)); + continue; + } + + let is_uncommitted = + repo.staged_count > 0 || repo.unstaged_count > 0 || repo.untracked_count > 0; + let is_behind = repo.behind > 0; + + if args.uncommitted && !is_uncommitted { + continue; + } + if args.behind && !is_behind { + continue; + } + + if is_uncommitted { + uncommitted_count += 1; + } + if is_behind { + behind_count += 1; + } + + // Build display name: "org/name" or just the path's last segment + let name = repo + .path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("?"); + let full_name = match &repo.org { + Some(org) => format!("{}/{}", org, name), + None => name.to_string(), + }; + + if args.detailed { + println!("{}", full_name); + println!(" Branch: {}", repo.current_branch); + if repo.ahead > 0 || repo.behind > 0 { + println!(" Ahead: {}, Behind: {}", repo.ahead, repo.behind); + } + if repo.staged_count > 0 || repo.unstaged_count > 0 { + println!(" Status: uncommitted changes"); + } + if repo.untracked_count > 0 { + println!(" Status: has untracked files"); + } + } else { + let mut indicators = Vec::new(); + if is_uncommitted { + indicators.push("*".to_string()); + } + if repo.ahead > 0 { + indicators.push(format!("+{}", repo.ahead)); + } + if repo.behind > 0 { + indicators.push(format!("-{}", repo.behind)); + } + + if indicators.is_empty() { + println!(" {} (clean)", full_name); + } else { + println!(" {} [{}]", full_name, indicators.join(", ")); + } + } + } + + // Summary + println!(); + if uncommitted_count > 0 { + output.warn(&format!( + "{} repositories have uncommitted changes", + uncommitted_count + )); + } + if behind_count > 0 { + output.info(&format!( + "{} repositories are behind upstream", + behind_count + )); + } + if error_count > 0 { + output.warn(&format!( + "{} repositories could not be checked", + error_count + )); + } else if uncommitted_count == 0 && behind_count == 0 { + output.success("All repositories are clean and up to date"); + } + + Ok(()) +} + +#[cfg(test)] +#[path = "status_tests.rs"] +mod tests; diff --git a/src/commands/status_tests.rs b/crates/git-same-cli/src/commands/status_tests.rs similarity index 95% rename from src/commands/status_tests.rs rename to crates/git-same-cli/src/commands/status_tests.rs index 41a1b3f..4fa081a 100644 --- a/src/commands/status_tests.rs +++ b/crates/git-same-cli/src/commands/status_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::output::Verbosity; +use git_same_core::output::Verbosity; fn quiet_output() -> Output { Output::new(Verbosity::Quiet, false) diff --git a/src/commands/support/concurrency.rs b/crates/git-same-cli/src/commands/support/concurrency.rs similarity index 85% rename from src/commands/support/concurrency.rs rename to crates/git-same-cli/src/commands/support/concurrency.rs index 3f29541..4245ef6 100644 --- a/src/commands/support/concurrency.rs +++ b/crates/git-same-cli/src/commands/support/concurrency.rs @@ -1,5 +1,5 @@ -use crate::operations::clone::MAX_CONCURRENCY; -use crate::output::Output; +use git_same_core::operations::clone::MAX_CONCURRENCY; +use git_same_core::output::Output; /// Warn if requested concurrency exceeds the maximum. /// Returns the effective concurrency to use. diff --git a/src/commands/support/concurrency_tests.rs b/crates/git-same-cli/src/commands/support/concurrency_tests.rs similarity index 92% rename from src/commands/support/concurrency_tests.rs rename to crates/git-same-cli/src/commands/support/concurrency_tests.rs index f622572..46f5010 100644 --- a/src/commands/support/concurrency_tests.rs +++ b/crates/git-same-cli/src/commands/support/concurrency_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::output::{Output, Verbosity}; +use git_same_core::output::{Output, Verbosity}; fn quiet_output() -> Output { Output::new(Verbosity::Quiet, false) diff --git a/src/commands/support/mod.rs b/crates/git-same-cli/src/commands/support/mod.rs similarity index 100% rename from src/commands/support/mod.rs rename to crates/git-same-cli/src/commands/support/mod.rs diff --git a/src/commands/support/workspace.rs b/crates/git-same-cli/src/commands/support/workspace.rs similarity index 87% rename from src/commands/support/workspace.rs rename to crates/git-same-cli/src/commands/support/workspace.rs index e0b34d4..b096e92 100644 --- a/src/commands/support/workspace.rs +++ b/crates/git-same-cli/src/commands/support/workspace.rs @@ -1,6 +1,6 @@ -use crate::config::WorkspaceConfig; -use crate::errors::{AppError, Result}; -use crate::output::Output; +use git_same_core::config::WorkspaceConfig; +use git_same_core::errors::{AppError, Result}; +use git_same_core::output::Output; /// Ensure the workspace root path exists. /// diff --git a/src/commands/support/workspace_tests.rs b/crates/git-same-cli/src/commands/support/workspace_tests.rs similarity index 95% rename from src/commands/support/workspace_tests.rs rename to crates/git-same-cli/src/commands/support/workspace_tests.rs index 19ab951..73f1cc0 100644 --- a/src/commands/support/workspace_tests.rs +++ b/crates/git-same-cli/src/commands/support/workspace_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::output::{Output, Verbosity}; +use git_same_core::output::{Output, Verbosity}; #[test] fn ensure_base_path_is_noop_when_path_exists() { diff --git a/src/commands/sync_cmd.rs b/crates/git-same-cli/src/commands/sync_cmd.rs similarity index 85% rename from src/commands/sync_cmd.rs rename to crates/git-same-cli/src/commands/sync_cmd.rs index 1abc60e..add8571 100644 --- a/src/commands/sync_cmd.rs +++ b/crates/git-same-cli/src/commands/sync_cmd.rs @@ -4,14 +4,14 @@ use super::warn_if_concurrency_capped; use crate::cli::SyncCmdArgs; -use crate::config::{Config, WorkspaceManager}; -use crate::errors::Result; -use crate::operations::clone::CloneProgress; -use crate::operations::sync::{SyncMode, SyncProgress}; -use crate::output::{ +use git_same_core::config::{Config, WorkspaceManager}; +use git_same_core::errors::Result; +use git_same_core::operations::clone::CloneProgress; +use git_same_core::operations::sync::{SyncMode, SyncProgress}; +use git_same_core::output::{ format_count, CloneProgressBar, DiscoveryProgressBar, Output, SyncProgressBar, Verbosity, }; -use crate::workflows::sync_workspace::{ +use git_same_core::workflows::sync_workspace::{ execute_prepared_sync, prepare_sync_workspace, SyncWorkspaceRequest, }; use std::sync::Arc; @@ -169,9 +169,28 @@ pub async fn run(args: &SyncCmdArgs, config: &Config, output: &Output) -> Result output.verbose(&format!("Warning: Failed to update last_synced: {}", e)); } + // Best-effort: nudge the Finder monitor so badges refresh for new clones. + // If the monitor is not running we silently skip; sync still succeeded. + nudge_monitor_refresh().await; + Ok(()) } +#[cfg(unix)] +async fn nudge_monitor_refresh() { + use git_same_core::ipc::{IpcConfig, UnixSocketClient}; + let Ok(cfg) = IpcConfig::default_path() else { + return; + }; + let client = UnixSocketClient::new(cfg.socket_path()); + if let Err(e) = client.refresh_all().await { + tracing::debug!(error = %e, "Monitor refresh nudge skipped"); + } +} + +#[cfg(not(unix))] +async fn nudge_monitor_refresh() {} + #[cfg(test)] #[path = "sync_cmd_tests.rs"] mod tests; diff --git a/src/commands/sync_cmd_tests.rs b/crates/git-same-cli/src/commands/sync_cmd_tests.rs similarity index 92% rename from src/commands/sync_cmd_tests.rs rename to crates/git-same-cli/src/commands/sync_cmd_tests.rs index 9ad29e6..db3c096 100644 --- a/src/commands/sync_cmd_tests.rs +++ b/crates/git-same-cli/src/commands/sync_cmd_tests.rs @@ -1,8 +1,5 @@ use super::*; -use crate::output::{Output, Verbosity}; -use tokio::sync::Mutex; - -static HOME_LOCK: Mutex<()> = Mutex::const_new(()); +use git_same_core::output::{Output, Verbosity}; fn default_args() -> SyncCmdArgs { SyncCmdArgs { @@ -17,7 +14,7 @@ fn default_args() -> SyncCmdArgs { #[tokio::test] async fn run_returns_error_when_no_workspace_is_configured() { - let _lock = HOME_LOCK.lock().await; + let _lock = crate::test_support::ENV_LOCK.lock().await; let original_home = std::env::var("HOME").ok(); let temp = tempfile::tempdir().unwrap(); std::env::set_var("HOME", temp.path()); @@ -53,7 +50,7 @@ async fn run_returns_error_when_no_workspace_is_configured() { #[tokio::test] async fn run_returns_error_for_unknown_workspace_name() { - let _lock = HOME_LOCK.lock().await; + let _lock = crate::test_support::ENV_LOCK.lock().await; let original_home = std::env::var("HOME").ok(); let temp = tempfile::tempdir().unwrap(); std::env::set_var("HOME", temp.path()); diff --git a/src/commands/workspace.rs b/crates/git-same-cli/src/commands/workspace.rs similarity index 90% rename from src/commands/workspace.rs rename to crates/git-same-cli/src/commands/workspace.rs index c9a3cc9..7841766 100644 --- a/src/commands/workspace.rs +++ b/crates/git-same-cli/src/commands/workspace.rs @@ -1,9 +1,9 @@ //! Workspace management command handler. use crate::cli::{WorkspaceArgs, WorkspaceCommand}; -use crate::config::{Config, WorkspaceManager}; -use crate::errors::Result; -use crate::output::Output; +use git_same_core::config::{Config, WorkspaceManager}; +use git_same_core::errors::Result; +use git_same_core::output::Output; /// Run the workspace command. pub fn run(args: &WorkspaceArgs, config: &Config, output: &Output) -> Result<()> { @@ -32,7 +32,7 @@ fn list(config: &Config, output: &Output) -> Result<()> { let default_path = config.default_workspace.as_deref().unwrap_or(""); for ws in &workspaces { - let ws_path = crate::config::workspace::tilde_collapse_path(&ws.root_path); + let ws_path = git_same_core::config::workspace::tilde_collapse_path(&ws.root_path); let marker = if ws_path == default_path { "*" } else { " " }; let last_synced = ws.last_synced.as_deref().unwrap_or("never"); let org_info = if ws.orgs.is_empty() { @@ -79,7 +79,7 @@ fn show_default(config: &Config, output: &Output) -> Result<()> { fn set_default(selector: &str, config: &Config, output: &Output) -> Result<()> { let ws = WorkspaceManager::resolve(Some(selector), config)?; - let tilde_path = crate::config::workspace::tilde_collapse_path(&ws.root_path); + let tilde_path = git_same_core::config::workspace::tilde_collapse_path(&ws.root_path); Config::save_default_workspace(Some(&tilde_path))?; output.success(&format!( "Default workspace set to '{}'", diff --git a/src/commands/workspace_tests.rs b/crates/git-same-cli/src/commands/workspace_tests.rs similarity index 95% rename from src/commands/workspace_tests.rs rename to crates/git-same-cli/src/commands/workspace_tests.rs index 024feaf..c4a74ee 100644 --- a/src/commands/workspace_tests.rs +++ b/crates/git-same-cli/src/commands/workspace_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::output::Verbosity; +use git_same_core::output::Verbosity; fn quiet_output() -> Output { Output::new(Verbosity::Quiet, false) diff --git a/crates/git-same-cli/src/lib.rs b/crates/git-same-cli/src/lib.rs new file mode 100644 index 0000000..88ddaef --- /dev/null +++ b/crates/git-same-cli/src/lib.rs @@ -0,0 +1,18 @@ +//! # git-same — CLI + TUI +//! +//! Library scaffolding for the `git-same` binary plus the release-tools +//! helpers `gen-completions` and `gen-manpage`. Implementation detail of +//! the binary; engine logic lives in `git-same-core`. + +pub mod app; +pub mod banner; +pub mod cli; +pub mod commands; +#[cfg(feature = "tui")] +pub mod setup; +#[cfg(test)] +pub(crate) mod test_support { + pub static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +} +#[cfg(feature = "tui")] +pub mod tui; diff --git a/src/main.rs b/crates/git-same-cli/src/main.rs similarity index 94% rename from src/main.rs rename to crates/git-same-cli/src/main.rs index 612b8b3..8dadfa1 100644 --- a/src/main.rs +++ b/crates/git-same-cli/src/main.rs @@ -3,7 +3,7 @@ //! Main entry point for the git-same CLI application. use git_same::app::cli::{run_command, Cli}; -use git_same::output::{Output, Verbosity}; +use git_same_core::output::{Output, Verbosity}; use std::process::ExitCode; use tracing::debug; @@ -11,8 +11,8 @@ use tracing::debug; /// /// Examples: /// - `GISA_LOG=debug` - Enable debug logging for all modules -/// - `GISA_LOG=git_same=debug` - Enable debug logging for git-same only -/// - `GISA_LOG=git_same::auth=trace` - Enable trace logging for auth module +/// - `GISA_LOG=git_same_core=debug` - Enable debug logging for the engine +/// - `GISA_LOG=git_same_core::auth=trace` - Enable trace logging for auth module /// - `GISA_LOG=warn` - Only show warnings and errors fn init_logging() { use tracing_subscriber::{fmt, prelude::*, EnvFilter}; @@ -57,7 +57,7 @@ async fn main() -> ExitCode { // No subcommand — launch TUI #[cfg(feature = "tui")] { - use git_same::config::Config; + use git_same_core::config::Config; // Auto-create default config if it doesn't exist let mut config_was_created = false; diff --git a/src/main_tests.rs b/crates/git-same-cli/src/main_tests.rs similarity index 100% rename from src/main_tests.rs rename to crates/git-same-cli/src/main_tests.rs diff --git a/src/setup/handler.rs b/crates/git-same-cli/src/setup/handler.rs similarity index 89% rename from src/setup/handler.rs rename to crates/git-same-cli/src/setup/handler.rs index a13cea7..a46867e 100644 --- a/src/setup/handler.rs +++ b/crates/git-same-cli/src/setup/handler.rs @@ -1,12 +1,10 @@ //! Setup wizard event handling. use super::state::{ - tilde_collapse, AuthStatus, OrgEntry, PathBrowseEntry, SetupOutcome, SetupState, SetupStep, + tilde_collapse, AuthStatus, PathBrowseEntry, SetupOutcome, SetupState, SetupStep, }; -use crate::auth::{get_auth_for_provider, gh_cli}; -use crate::config::{WorkspaceConfig, WorkspaceManager}; -use crate::provider::create_provider; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use git_same_core::setup::{authenticate_provider, discover_org_entries, save_workspace}; /// Handle a key event in the setup wizard. /// @@ -192,16 +190,13 @@ async fn handle_auth(state: &mut SetupState, key: KeyEvent) { async fn do_authenticate(state: &mut SetupState) { let ws_provider = state.build_workspace_provider(); - match get_auth_for_provider(&ws_provider) { + match authenticate_provider(ws_provider).await { Ok(auth) => { - let username = auth.username.or_else(|| gh_cli::get_username().ok()); - state.username = username; + state.username = auth.username; state.auth_token = Some(auth.token); state.auth_status = AuthStatus::Success; } - Err(e) => { - state.auth_status = AuthStatus::Failed(e.to_string()); - } + Err(e) => state.auth_status = AuthStatus::Failed(e), } } @@ -626,35 +621,6 @@ async fn do_discover_orgs(state: &mut SetupState) { } } -pub(crate) async fn discover_org_entries( - ws_provider: crate::config::WorkspaceProvider, - token: String, -) -> Result, String> { - match create_provider(&ws_provider, &token) { - Ok(provider) => match provider.get_organizations().await { - Ok(orgs) => { - let mut org_entries: Vec = Vec::new(); - for org in &orgs { - let repo_count = provider - .get_org_repos(&org.login) - .await - .map(|r| r.len()) - .unwrap_or(0); - org_entries.push(OrgEntry { - name: org.login.clone(), - repo_count, - selected: true, - }); - } - org_entries.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(org_entries) - } - Err(e) => Err(e.to_string()), - }, - Err(e) => Err(e.to_string()), - } -} - fn handle_confirm(state: &mut SetupState, key: KeyEvent) { match key.code { KeyCode::Enter => { @@ -687,27 +653,6 @@ fn handle_complete(state: &mut SetupState, key: KeyEvent) { } } -fn save_workspace(state: &SetupState) -> Result<(), crate::errors::AppError> { - let expanded = shellexpand::tilde(&state.base_path); - let root = std::path::Path::new(expanded.as_ref()); - std::fs::create_dir_all(root).map_err(|e| { - crate::errors::AppError::config(format!( - "Failed to create workspace directory '{}': {}", - root.display(), - e - )) - })?; - let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); - - let mut ws = WorkspaceConfig::new_from_root(&root); - ws.provider = state.build_workspace_provider(); - ws.username = state.username.clone().unwrap_or_default(); - ws.orgs = state.selected_orgs(); - - WorkspaceManager::save(&ws)?; - Ok(()) -} - #[cfg(test)] #[path = "handler_tests.rs"] mod tests; diff --git a/src/setup/handler_tests.rs b/crates/git-same-cli/src/setup/handler_tests.rs similarity index 97% rename from src/setup/handler_tests.rs rename to crates/git-same-cli/src/setup/handler_tests.rs index cca287c..24f09a8 100644 --- a/src/setup/handler_tests.rs +++ b/crates/git-same-cli/src/setup/handler_tests.rs @@ -13,6 +13,10 @@ fn tempdir_in_cwd(prefix: &str) -> tempfile::TempDir { .unwrap() } +async fn lock_process_env() -> tokio::sync::MutexGuard<'static, ()> { + crate::test_support::ENV_LOCK.lock().await +} + fn find_entry_index(state: &SetupState, path: &std::path::Path) -> usize { let wanted = super::tilde_collapse(&path.to_string_lossy()); state @@ -153,6 +157,7 @@ async fn enter_in_suggestions_mode_does_not_change_base_path() { #[tokio::test] async fn b_opens_path_browser_from_suggestions_mode() { + let _env_lock = lock_process_env().await; let temp = tempdir_in_cwd("gisa-path-browse-"); std::fs::create_dir_all(temp.path().join("child")).unwrap(); @@ -207,6 +212,7 @@ async fn left_on_root_moves_popup_to_parent_directory() { #[tokio::test] async fn right_in_path_browse_mode_navigates_tree_without_advancing_step() { + let _env_lock = lock_process_env().await; let temp = tempdir_in_cwd("gisa-path-nav-"); let alpha = temp.path().join("alpha"); std::fs::create_dir_all(&alpha).unwrap(); @@ -246,6 +252,7 @@ async fn right_in_path_browse_mode_navigates_tree_without_advancing_step() { #[tokio::test] async fn enter_in_browse_mode_sets_path_and_closes_popup() { + let _env_lock = lock_process_env().await; let temp = tempdir_in_cwd("gisa-path-enter-"); let alpha = temp.path().join("alpha"); std::fs::create_dir_all(&alpha).unwrap(); @@ -317,6 +324,7 @@ async fn esc_in_popup_only_closes_popup() { #[tokio::test] async fn left_moves_to_parent_and_then_collapses() { + let _env_lock = lock_process_env().await; let temp = tempdir_in_cwd("gisa-path-left-"); let alpha = temp.path().join("alpha"); let nested = alpha.join("nested"); @@ -362,6 +370,7 @@ async fn left_moves_to_parent_and_then_collapses() { #[tokio::test] async fn right_on_leaf_does_not_change_selection_until_enter() { + let _env_lock = lock_process_env().await; let leaf_temp = tempdir_in_cwd("gisa-path-leaf-"); let expected = super::tilde_collapse(&leaf_temp.path().to_string_lossy()); @@ -393,6 +402,7 @@ async fn right_on_leaf_does_not_change_selection_until_enter() { #[tokio::test] async fn very_large_directory_list_is_loaded() { + let _env_lock = lock_process_env().await; let temp = tempdir_in_cwd("gisa-path-many-"); for i in 0..150 { std::fs::create_dir_all(temp.path().join(format!("d{i:03}"))).unwrap(); @@ -445,7 +455,7 @@ async fn enter_while_checks_loading_does_not_advance_requirements_step() { state.step = SetupStep::Requirements; state.checks_loading = true; // Plant a passing result to confirm loading flag is the only blocker. - state.check_results = vec![crate::checks::CheckResult { + state.check_results = vec![git_same_core::checks::CheckResult { name: "git".to_string(), passed: true, message: "git 2.40".to_string(), @@ -485,7 +495,7 @@ async fn enter_when_critical_check_failed_does_not_advance() { let mut state = SetupState::new("~/Git-Same/GitHub"); state.step = SetupStep::Requirements; state.checks_loading = false; - state.check_results = vec![crate::checks::CheckResult { + state.check_results = vec![git_same_core::checks::CheckResult { name: "git".to_string(), passed: false, message: "not found".to_string(), @@ -508,7 +518,7 @@ async fn enter_when_requirements_passed_and_not_loading_advances_step() { let mut state = SetupState::new("~/Git-Same/GitHub"); state.step = SetupStep::Requirements; state.checks_loading = false; - state.check_results = vec![crate::checks::CheckResult { + state.check_results = vec![git_same_core::checks::CheckResult { name: "git".to_string(), passed: true, message: "git 2.40".to_string(), @@ -687,6 +697,7 @@ async fn space_toggles_org_selection() { /// Up at path_browse_index == 0 must not move (underflow guard). #[tokio::test] async fn up_at_first_browse_entry_does_not_move() { + let _env_lock = lock_process_env().await; let temp = tempdir_in_cwd("gisa-hbrowse-up-"); let alpha = temp.path().join("a-dir"); std::fs::create_dir_all(&alpha).unwrap(); @@ -713,6 +724,7 @@ async fn up_at_first_browse_entry_does_not_move() { /// Down at the last browse entry must not move past the end. #[tokio::test] async fn down_at_last_browse_entry_does_not_move() { + let _env_lock = lock_process_env().await; let temp = tempdir_in_cwd("gisa-hbrowse-dn-"); let alpha = temp.path().join("only-child"); std::fs::create_dir_all(&alpha).unwrap(); diff --git a/src/setup/mod.rs b/crates/git-same-cli/src/setup/mod.rs similarity index 83% rename from src/setup/mod.rs rename to crates/git-same-cli/src/setup/mod.rs index 3fcbbe2..e783e80 100644 --- a/src/setup/mod.rs +++ b/crates/git-same-cli/src/setup/mod.rs @@ -9,12 +9,15 @@ pub mod screens; pub mod state; pub mod ui; -use crate::errors::Result; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture, Event as CtEvent}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; +use git_same_core::errors::Result; +pub(crate) use git_same_core::setup::{ + apply_requirements_check_results, maybe_start_requirements_checks, run_requirements_checks, +}; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use state::{SetupOutcome, SetupState, SetupStep}; @@ -132,33 +135,3 @@ async fn run_wizard( } Ok(()) } - -pub(crate) fn maybe_start_requirements_checks(state: &mut SetupState) -> bool { - if state.step != SetupStep::Requirements || state.checks_triggered { - return false; - } - - state.checks_triggered = true; - state.checks_loading = true; - state.config_path_display = crate::config::Config::default_path() - .ok() - .map(|p| p.display().to_string()); - true -} - -pub(crate) fn apply_requirements_check_results( - state: &mut SetupState, - results: Vec, -) { - state.check_results = results; - state.checks_loading = false; -} - -pub(crate) async fn run_requirements_checks(state: &mut SetupState) { - let results = crate::checks::check_requirements().await; - apply_requirements_check_results(state, results); -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod tests; diff --git a/src/setup/screens/auth.rs b/crates/git-same-cli/src/setup/screens/auth.rs similarity index 100% rename from src/setup/screens/auth.rs rename to crates/git-same-cli/src/setup/screens/auth.rs diff --git a/src/setup/screens/auth_tests.rs b/crates/git-same-cli/src/setup/screens/auth_tests.rs similarity index 100% rename from src/setup/screens/auth_tests.rs rename to crates/git-same-cli/src/setup/screens/auth_tests.rs diff --git a/src/setup/screens/complete.rs b/crates/git-same-cli/src/setup/screens/complete.rs similarity index 100% rename from src/setup/screens/complete.rs rename to crates/git-same-cli/src/setup/screens/complete.rs diff --git a/src/setup/screens/complete_tests.rs b/crates/git-same-cli/src/setup/screens/complete_tests.rs similarity index 100% rename from src/setup/screens/complete_tests.rs rename to crates/git-same-cli/src/setup/screens/complete_tests.rs diff --git a/src/setup/screens/confirm.rs b/crates/git-same-cli/src/setup/screens/confirm.rs similarity index 100% rename from src/setup/screens/confirm.rs rename to crates/git-same-cli/src/setup/screens/confirm.rs diff --git a/src/setup/screens/confirm_tests.rs b/crates/git-same-cli/src/setup/screens/confirm_tests.rs similarity index 100% rename from src/setup/screens/confirm_tests.rs rename to crates/git-same-cli/src/setup/screens/confirm_tests.rs diff --git a/src/setup/screens/mod.rs b/crates/git-same-cli/src/setup/screens/mod.rs similarity index 100% rename from src/setup/screens/mod.rs rename to crates/git-same-cli/src/setup/screens/mod.rs diff --git a/src/setup/screens/mod_tests.rs b/crates/git-same-cli/src/setup/screens/mod_tests.rs similarity index 100% rename from src/setup/screens/mod_tests.rs rename to crates/git-same-cli/src/setup/screens/mod_tests.rs diff --git a/src/setup/screens/orgs.rs b/crates/git-same-cli/src/setup/screens/orgs.rs similarity index 100% rename from src/setup/screens/orgs.rs rename to crates/git-same-cli/src/setup/screens/orgs.rs diff --git a/src/setup/screens/orgs_tests.rs b/crates/git-same-cli/src/setup/screens/orgs_tests.rs similarity index 100% rename from src/setup/screens/orgs_tests.rs rename to crates/git-same-cli/src/setup/screens/orgs_tests.rs diff --git a/src/setup/screens/path.rs b/crates/git-same-cli/src/setup/screens/path.rs similarity index 100% rename from src/setup/screens/path.rs rename to crates/git-same-cli/src/setup/screens/path.rs diff --git a/src/setup/screens/path_tests.rs b/crates/git-same-cli/src/setup/screens/path_tests.rs similarity index 100% rename from src/setup/screens/path_tests.rs rename to crates/git-same-cli/src/setup/screens/path_tests.rs diff --git a/src/setup/screens/provider.rs b/crates/git-same-cli/src/setup/screens/provider.rs similarity index 98% rename from src/setup/screens/provider.rs rename to crates/git-same-cli/src/setup/screens/provider.rs index 393c7c4..1447e09 100644 --- a/src/setup/screens/provider.rs +++ b/crates/git-same-cli/src/setup/screens/provider.rs @@ -1,7 +1,7 @@ //! Step 1: Provider selection screen with descriptions. use crate::setup::state::SetupState; -use crate::types::ProviderKind; +use git_same_core::types::ProviderKind; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; diff --git a/src/setup/screens/provider_tests.rs b/crates/git-same-cli/src/setup/screens/provider_tests.rs similarity index 97% rename from src/setup/screens/provider_tests.rs rename to crates/git-same-cli/src/setup/screens/provider_tests.rs index 04eb753..f04b654 100644 --- a/src/setup/screens/provider_tests.rs +++ b/crates/git-same-cli/src/setup/screens/provider_tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::setup::state::SetupState; -use crate::types::ProviderKind; +use git_same_core::types::ProviderKind; use ratatui::backend::TestBackend; use ratatui::Terminal; diff --git a/src/setup/screens/requirements.rs b/crates/git-same-cli/src/setup/screens/requirements.rs similarity index 100% rename from src/setup/screens/requirements.rs rename to crates/git-same-cli/src/setup/screens/requirements.rs diff --git a/src/setup/screens/requirements_tests.rs b/crates/git-same-cli/src/setup/screens/requirements_tests.rs similarity index 95% rename from src/setup/screens/requirements_tests.rs rename to crates/git-same-cli/src/setup/screens/requirements_tests.rs index 25bf7f2..c36e1c4 100644 --- a/src/setup/screens/requirements_tests.rs +++ b/crates/git-same-cli/src/setup/screens/requirements_tests.rs @@ -51,7 +51,7 @@ fn render_loading_shows_spinner() { fn render_passed_checks_shows_continue_hint() { let mut state = SetupState::new("~/Git-Same/GitHub"); state.checks_loading = false; - state.check_results = vec![crate::checks::CheckResult { + state.check_results = vec![git_same_core::checks::CheckResult { name: "Git".to_string(), passed: true, message: "git 2.43.0".to_string(), @@ -66,7 +66,7 @@ fn render_passed_checks_shows_continue_hint() { fn render_failed_critical_shows_fix_hint() { let mut state = SetupState::new("~/Git-Same/GitHub"); state.checks_loading = false; - state.check_results = vec![crate::checks::CheckResult { + state.check_results = vec![git_same_core::checks::CheckResult { name: "Git".to_string(), passed: false, message: "not found".to_string(), diff --git a/crates/git-same-cli/src/setup/state.rs b/crates/git-same-cli/src/setup/state.rs new file mode 100644 index 0000000..e3d3e10 --- /dev/null +++ b/crates/git-same-cli/src/setup/state.rs @@ -0,0 +1,3 @@ +//! Re-exports for setup state shared from `git-same-core`. + +pub use git_same_core::setup::state::*; diff --git a/src/setup/ui.rs b/crates/git-same-cli/src/setup/ui.rs similarity index 100% rename from src/setup/ui.rs rename to crates/git-same-cli/src/setup/ui.rs diff --git a/src/setup/ui_tests.rs b/crates/git-same-cli/src/setup/ui_tests.rs similarity index 100% rename from src/setup/ui_tests.rs rename to crates/git-same-cli/src/setup/ui_tests.rs diff --git a/src/tui/app.rs b/crates/git-same-cli/src/tui/app.rs similarity index 91% rename from src/tui/app.rs rename to crates/git-same-cli/src/tui/app.rs index 0a48ffb..fd2bd3d 100644 --- a/src/tui/app.rs +++ b/crates/git-same-cli/src/tui/app.rs @@ -1,10 +1,9 @@ //! TUI application state (the "Model" in Elm architecture). -use crate::config::{Config, WorkspaceConfig}; use crate::setup::state::{self, SetupState}; -use crate::types::{OpSummary, OwnedRepo}; +use git_same_core::config::{Config, WorkspaceConfig}; +use git_same_core::types::{OpSummary, OwnedRepo, RepoEntry, SyncHistoryEntry}; use ratatui::widgets::TableState; -use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; use std::time::Instant; @@ -126,35 +125,6 @@ pub enum LogFilter { Changelog, } -/// A summary entry for sync history. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncHistoryEntry { - pub timestamp: String, - pub duration_secs: f64, - pub success: usize, - pub failed: usize, - pub skipped: usize, - pub with_updates: usize, - pub cloned: usize, - pub total_new_commits: u32, -} - -/// A local repo with its computed status. -#[derive(Debug, Clone)] -pub struct RepoEntry { - pub owner: String, - pub name: String, - pub full_name: String, - pub path: PathBuf, - pub branch: Option, - pub is_uncommitted: bool, - pub ahead: usize, - pub behind: usize, - pub staged_count: usize, - pub unstaged_count: usize, - pub untracked_count: usize, -} - /// A requirement check result for the init check screen. #[derive(Debug, Clone)] pub struct CheckEntry { @@ -331,7 +301,7 @@ impl App { let sync_history = active_workspace .as_ref() .and_then(|ws| { - crate::cache::SyncHistoryManager::for_workspace(&ws.root_path) + git_same_core::cache::SyncHistoryManager::for_workspace(&ws.root_path) .and_then(|m| m.load()) .ok() }) @@ -399,9 +369,10 @@ impl App { if let Some(ws) = self.workspaces.get(index).cloned() { self.base_path = Some(ws.expanded_base_path()); // Load sync history for this workspace - self.sync_history = crate::cache::SyncHistoryManager::for_workspace(&ws.root_path) - .and_then(|m| m.load()) - .unwrap_or_default(); + self.sync_history = + git_same_core::cache::SyncHistoryManager::for_workspace(&ws.root_path) + .and_then(|m| m.load()) + .unwrap_or_default(); self.active_workspace = Some(ws); // Reset discovered data when switching workspace self.repos_by_org.clear(); diff --git a/src/tui/app_tests.rs b/crates/git-same-cli/src/tui/app_tests.rs similarity index 100% rename from src/tui/app_tests.rs rename to crates/git-same-cli/src/tui/app_tests.rs diff --git a/src/tui/backend.rs b/crates/git-same-cli/src/tui/backend.rs similarity index 95% rename from src/tui/backend.rs rename to crates/git-same-cli/src/tui/backend.rs index f71c14a..e158a7e 100644 --- a/src/tui/backend.rs +++ b/crates/git-same-cli/src/tui/backend.rs @@ -6,14 +6,14 @@ use std::path::Path; use std::sync::Arc; use tokio::sync::mpsc::UnboundedSender; -use crate::config::{Config, WorkspaceConfig, WorkspaceProvider}; -use crate::git::{FetchResult, GitOperations, PullResult, ShellGit}; -use crate::operations::clone::CloneProgress; -use crate::operations::sync::SyncProgress; -use crate::provider::DiscoveryProgress; -use crate::types::{OpSummary, OwnedRepo}; -use crate::workflows::status_scan::scan_workspace_status; -use crate::workflows::sync_workspace::{ +use git_same_core::config::{Config, WorkspaceConfig, WorkspaceProvider}; +use git_same_core::git::{FetchResult, GitOperations, PullResult, ShellGit}; +use git_same_core::operations::clone::CloneProgress; +use git_same_core::operations::sync::SyncProgress; +use git_same_core::provider::DiscoveryProgress; +use git_same_core::types::{OpSummary, OwnedRepo}; +use git_same_core::workflows::status_scan::scan_workspace_status; +use git_same_core::workflows::sync_workspace::{ execute_prepared_sync, prepare_sync_workspace, SyncWorkspaceRequest, }; @@ -263,7 +263,7 @@ pub fn spawn_setup_org_discovery( tx: UnboundedSender, ) { tokio::spawn(async move { - match crate::setup::handler::discover_org_entries(ws_provider, token).await { + match git_same_core::setup::discover_org_entries(ws_provider, token).await { Ok(orgs) => { let _ = tx.send(AppEvent::Backend(BackendMessage::SetupOrgsDiscovered(orgs))); } diff --git a/src/tui/backend_tests.rs b/crates/git-same-cli/src/tui/backend_tests.rs similarity index 96% rename from src/tui/backend_tests.rs rename to crates/git-same-cli/src/tui/backend_tests.rs index 2677074..21008c9 100644 --- a/src/tui/backend_tests.rs +++ b/crates/git-same-cli/src/tui/backend_tests.rs @@ -1,11 +1,11 @@ use super::*; -use crate::config::Config; -use crate::git::{FetchResult, PullResult}; -use crate::operations::clone::CloneProgress; -use crate::operations::sync::SyncProgress; -use crate::provider::DiscoveryProgress; use crate::tui::event::{AppEvent, BackendMessage}; -use crate::types::{OwnedRepo, Repo}; +use git_same_core::config::Config; +use git_same_core::git::{FetchResult, PullResult}; +use git_same_core::operations::clone::CloneProgress; +use git_same_core::operations::sync::SyncProgress; +use git_same_core::provider::DiscoveryProgress; +use git_same_core::types::{OwnedRepo, Repo}; use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::unbounded_channel; use tokio::time::{timeout, Duration}; diff --git a/src/tui/event.rs b/crates/git-same-cli/src/tui/event.rs similarity index 97% rename from src/tui/event.rs rename to crates/git-same-cli/src/tui/event.rs index b590ed0..dc293d1 100644 --- a/src/tui/event.rs +++ b/crates/git-same-cli/src/tui/event.rs @@ -6,9 +6,9 @@ use tokio::sync::mpsc; use tracing::warn; use crate::setup::state::OrgEntry; -use crate::types::{OpSummary, OwnedRepo}; +use git_same_core::types::{OpSummary, OwnedRepo, RepoEntry}; -use super::app::{CheckEntry, Operation, RepoEntry}; +use super::app::{CheckEntry, Operation}; /// Events that the TUI loop processes. #[derive(Debug)] diff --git a/src/tui/event_tests.rs b/crates/git-same-cli/src/tui/event_tests.rs similarity index 95% rename from src/tui/event_tests.rs rename to crates/git-same-cli/src/tui/event_tests.rs index 84a8392..8fe13ec 100644 --- a/src/tui/event_tests.rs +++ b/crates/git-same-cli/src/tui/event_tests.rs @@ -1,12 +1,12 @@ use super::*; use crate::setup::state::OrgEntry; -use crate::tui::app::{CheckEntry, Operation, RepoEntry}; -use crate::types::OpSummary; +use crate::tui::app::{CheckEntry, Operation}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use git_same_core::types::{OpSummary, RepoEntry}; use std::path::PathBuf; fn sample_repo() -> OwnedRepo { - OwnedRepo::new("acme", crate::types::Repo::test("rocket", "acme")) + OwnedRepo::new("acme", git_same_core::types::Repo::test("rocket", "acme")) } fn sample_repo_entry() -> RepoEntry { diff --git a/src/tui/handler.rs b/crates/git-same-cli/src/tui/handler.rs similarity index 98% rename from src/tui/handler.rs rename to crates/git-same-cli/src/tui/handler.rs index 1828c50..724e2b5 100644 --- a/src/tui/handler.rs +++ b/crates/git-same-cli/src/tui/handler.rs @@ -4,15 +4,15 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tokio::sync::mpsc::UnboundedSender; use super::app::{ - App, CheckEntry, LogFilter, Operation, OperationState, Screen, SyncHistoryEntry, SyncLogEntry, - SyncLogStatus, + App, CheckEntry, LogFilter, Operation, OperationState, Screen, SyncLogEntry, SyncLogStatus, }; use super::event::{AppEvent, BackendMessage}; use super::screens; -use crate::cache::SyncHistoryManager; -use crate::config::WorkspaceManager; -use crate::domain::RepoPathTemplate; use crate::setup::state::{SetupOutcome, SetupStep}; +use git_same_core::cache::SyncHistoryManager; +use git_same_core::config::WorkspaceManager; +use git_same_core::domain::RepoPathTemplate; +use git_same_core::types::SyncHistoryEntry; const MAX_THROUGHPUT_SAMPLES: usize = 240; const MAX_LOG_LINES: usize = 5_000; @@ -66,7 +66,7 @@ pub async fn handle_event(app: &mut App, event: AppEvent, backend_tx: &Unbounded if crate::setup::maybe_start_requirements_checks(setup) { let tx = backend_tx.clone(); tokio::spawn(async move { - let results = crate::checks::check_requirements().await; + let results = git_same_core::checks::check_requirements().await; let entries: Vec = results .into_iter() .map(|r| CheckEntry { @@ -110,7 +110,7 @@ pub async fn handle_event(app: &mut App, event: AppEvent, backend_tx: &Unbounded app.checks_loading = true; let tx = backend_tx.clone(); tokio::spawn(async move { - let results = crate::checks::check_requirements().await; + let results = git_same_core::checks::check_requirements().await; let entries: Vec = results .into_iter() .map(|r| CheckEntry { @@ -605,7 +605,7 @@ fn handle_backend_message( // Map CheckEntry back to CheckResult for setup state storage let results = entries .iter() - .map(|e| crate::checks::CheckResult { + .map(|e| git_same_core::checks::CheckResult { name: e.name.clone(), passed: e.passed, message: e.message.clone(), diff --git a/src/tui/handler_tests.rs b/crates/git-same-cli/src/tui/handler_tests.rs similarity index 98% rename from src/tui/handler_tests.rs rename to crates/git-same-cli/src/tui/handler_tests.rs index 9df93e4..369c983 100644 --- a/src/tui/handler_tests.rs +++ b/crates/git-same-cli/src/tui/handler_tests.rs @@ -1,8 +1,8 @@ use super::*; -use crate::config::{Config, WorkspaceConfig}; use crate::setup::state::{OrgEntry, SetupState, SetupStep}; use crate::tui::event::{AppEvent, BackendMessage}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use git_same_core::config::{Config, WorkspaceConfig}; use tokio::sync::mpsc::unbounded_channel; #[tokio::test] diff --git a/src/tui/mod.rs b/crates/git-same-cli/src/tui/mod.rs similarity index 96% rename from src/tui/mod.rs rename to crates/git-same-cli/src/tui/mod.rs index 43cbfe3..fd23e2d 100644 --- a/src/tui/mod.rs +++ b/crates/git-same-cli/src/tui/mod.rs @@ -10,13 +10,13 @@ pub mod screens; pub mod ui; pub mod widgets; -use crate::config::{Config, WorkspaceManager}; -use crate::errors::Result; use app::App; use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; +use git_same_core::config::{Config, WorkspaceManager}; +use git_same_core::errors::Result; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use std::io; diff --git a/src/tui/screens/dashboard.rs b/crates/git-same-cli/src/tui/screens/dashboard.rs similarity index 99% rename from src/tui/screens/dashboard.rs rename to crates/git-same-cli/src/tui/screens/dashboard.rs index e862d18..7acf9e7 100644 --- a/src/tui/screens/dashboard.rs +++ b/crates/git-same-cli/src/tui/screens/dashboard.rs @@ -16,8 +16,9 @@ use crossterm::event::{KeyCode, KeyEvent}; use tokio::sync::mpsc::UnboundedSender; use crate::banner::{render_animated_banner, render_banner}; -use crate::tui::app::{App, Operation, OperationState, RepoEntry, Screen}; +use crate::tui::app::{App, Operation, OperationState, Screen}; use crate::tui::event::AppEvent; +use git_same_core::types::RepoEntry; // ── Key handler ───────────────────────────────────────────────────────────── diff --git a/src/tui/screens/dashboard_tests.rs b/crates/git-same-cli/src/tui/screens/dashboard_tests.rs similarity index 99% rename from src/tui/screens/dashboard_tests.rs rename to crates/git-same-cli/src/tui/screens/dashboard_tests.rs index b3811a3..4f5cbf0 100644 --- a/src/tui/screens/dashboard_tests.rs +++ b/crates/git-same-cli/src/tui/screens/dashboard_tests.rs @@ -1,6 +1,6 @@ use super::*; -use crate::config::{Config, WorkspaceConfig}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use git_same_core::config::{Config, WorkspaceConfig}; use tokio::sync::mpsc::unbounded_channel; fn build_app() -> App { diff --git a/src/tui/screens/mod.rs b/crates/git-same-cli/src/tui/screens/mod.rs similarity index 100% rename from src/tui/screens/mod.rs rename to crates/git-same-cli/src/tui/screens/mod.rs diff --git a/src/tui/screens/settings.rs b/crates/git-same-cli/src/tui/screens/settings.rs similarity index 98% rename from src/tui/screens/settings.rs rename to crates/git-same-cli/src/tui/screens/settings.rs index f26d8c3..d14dbb3 100644 --- a/src/tui/screens/settings.rs +++ b/crates/git-same-cli/src/tui/screens/settings.rs @@ -27,7 +27,7 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { } KeyCode::Char('c') => { // Open config directory in Finder / file manager - if let Ok(path) = crate::config::Config::default_path() { + if let Ok(path) = git_same_core::config::Config::default_path() { if let Some(parent) = path.parent() { if let Err(e) = open_directory(parent) { app.error_message = Some(format!( @@ -218,7 +218,7 @@ fn render_options_detail(app: &App, frame: &mut Frame, area: Rect) { .fg(Color::Rgb(21, 128, 61)) .add_modifier(Modifier::BOLD); - let config_path = crate::config::Config::default_path() + let config_path = git_same_core::config::Config::default_path() .ok() .and_then(|p| p.parent().map(|parent| parent.display().to_string())) .unwrap_or_else(|| "~/.config/git-same".to_string()); diff --git a/src/tui/screens/settings_tests.rs b/crates/git-same-cli/src/tui/screens/settings_tests.rs similarity index 97% rename from src/tui/screens/settings_tests.rs rename to crates/git-same-cli/src/tui/screens/settings_tests.rs index f8da0b2..2420a7e 100644 --- a/src/tui/screens/settings_tests.rs +++ b/crates/git-same-cli/src/tui/screens/settings_tests.rs @@ -1,6 +1,6 @@ use super::*; -use crate::config::{Config, WorkspaceConfig}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use git_same_core::config::{Config, WorkspaceConfig}; use ratatui::backend::TestBackend; use ratatui::Terminal; diff --git a/src/tui/screens/sync.rs b/crates/git-same-cli/src/tui/screens/sync.rs similarity index 100% rename from src/tui/screens/sync.rs rename to crates/git-same-cli/src/tui/screens/sync.rs diff --git a/src/tui/screens/sync_tests.rs b/crates/git-same-cli/src/tui/screens/sync_tests.rs similarity index 95% rename from src/tui/screens/sync_tests.rs rename to crates/git-same-cli/src/tui/screens/sync_tests.rs index 40a0076..ab96d4f 100644 --- a/src/tui/screens/sync_tests.rs +++ b/crates/git-same-cli/src/tui/screens/sync_tests.rs @@ -1,8 +1,8 @@ use super::*; -use crate::config::{Config, WorkspaceConfig}; use crate::tui::app::{Operation, Screen}; -use crate::types::OpSummary; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use git_same_core::config::{Config, WorkspaceConfig}; +use git_same_core::types::OpSummary; use tokio::sync::mpsc::unbounded_channel; fn build_app() -> App { diff --git a/src/tui/screens/workspaces.rs b/crates/git-same-cli/src/tui/screens/workspaces.rs similarity index 98% rename from src/tui/screens/workspaces.rs rename to crates/git-same-cli/src/tui/screens/workspaces.rs index 7f9638e..bc525d9 100644 --- a/src/tui/screens/workspaces.rs +++ b/crates/git-same-cli/src/tui/screens/workspaces.rs @@ -19,10 +19,10 @@ use tokio::sync::mpsc::UnboundedSender; use std::sync::atomic::{AtomicUsize, Ordering}; use crate::banner::render_banner; -use crate::config::{Config, SyncMode, WorkspaceConfig, WorkspaceManager}; use crate::setup::state::SetupState; use crate::tui::app::{App, Screen, WorkspacePane}; use crate::tui::event::{AppEvent, BackendMessage}; +use git_same_core::config::{Config, SyncMode, WorkspaceConfig, WorkspaceManager}; #[cfg(test)] static OPEN_WORKSPACE_FOLDER_CALLS: AtomicUsize = AtomicUsize::new(0); @@ -99,7 +99,7 @@ pub async fn handle_key(app: &mut App, key: KeyEvent, backend_tx: &UnboundedSend KeyCode::Char('d') if app.workspace_index < num_ws => { // Set default workspace if let Some(ws) = app.workspaces.get(app.workspace_index) { - let ws_path = crate::config::workspace::tilde_collapse_path(&ws.root_path); + let ws_path = git_same_core::config::workspace::tilde_collapse_path(&ws.root_path); let current_default = app.config.default_workspace.as_deref(); if current_default == Some(ws_path.as_str()) { // Already default, do nothing @@ -233,7 +233,7 @@ fn render_workspace_nav(app: &App, frame: &mut Frame, area: Rect) { .as_ref() .map(|aw| aw.root_path == ws.root_path) .unwrap_or(false); - let ws_path = crate::config::workspace::tilde_collapse_path(&ws.root_path); + let ws_path = git_same_core::config::workspace::tilde_collapse_path(&ws.root_path); let is_default = app.config.default_workspace.as_deref() == Some(ws_path.as_str()); let folder_name = ws @@ -316,7 +316,7 @@ fn render_workspace_detail(app: &App, ws: &WorkspaceConfig, frame: &mut Frame, a .fg(Color::Rgb(37, 99, 235)) .add_modifier(Modifier::BOLD); - let ws_tilde_path = crate::config::workspace::tilde_collapse_path(&ws.root_path); + let ws_tilde_path = git_same_core::config::workspace::tilde_collapse_path(&ws.root_path); let is_default = app .config diff --git a/src/tui/screens/workspaces_tests.rs b/crates/git-same-cli/src/tui/screens/workspaces_tests.rs similarity index 100% rename from src/tui/screens/workspaces_tests.rs rename to crates/git-same-cli/src/tui/screens/workspaces_tests.rs diff --git a/src/tui/ui.rs b/crates/git-same-cli/src/tui/ui.rs similarity index 100% rename from src/tui/ui.rs rename to crates/git-same-cli/src/tui/ui.rs diff --git a/src/tui/widgets/mod.rs b/crates/git-same-cli/src/tui/widgets/mod.rs similarity index 100% rename from src/tui/widgets/mod.rs rename to crates/git-same-cli/src/tui/widgets/mod.rs diff --git a/src/tui/widgets/repo_table.rs b/crates/git-same-cli/src/tui/widgets/repo_table.rs similarity index 97% rename from src/tui/widgets/repo_table.rs rename to crates/git-same-cli/src/tui/widgets/repo_table.rs index fed96e6..9dd3ba9 100644 --- a/src/tui/widgets/repo_table.rs +++ b/crates/git-same-cli/src/tui/widgets/repo_table.rs @@ -7,7 +7,7 @@ use ratatui::{ Frame, }; -use crate::types::OwnedRepo; +use git_same_core::types::OwnedRepo; /// Render a table of OwnedRepo entries. pub fn render_owned_repos( diff --git a/src/tui/widgets/repo_table_tests.rs b/crates/git-same-cli/src/tui/widgets/repo_table_tests.rs similarity index 87% rename from src/tui/widgets/repo_table_tests.rs rename to crates/git-same-cli/src/tui/widgets/repo_table_tests.rs index d77fa80..fe6b43b 100644 --- a/src/tui/widgets/repo_table_tests.rs +++ b/crates/git-same-cli/src/tui/widgets/repo_table_tests.rs @@ -26,8 +26,8 @@ fn render_output(repos: &[&OwnedRepo]) -> String { #[test] fn repo_table_renders_title_headers_and_rows() { - let public_repo = OwnedRepo::new("acme", crate::types::Repo::test("rocket", "acme")); - let mut private_repo = crate::types::Repo::test("vault", "acme"); + let public_repo = OwnedRepo::new("acme", git_same_core::types::Repo::test("rocket", "acme")); + let mut private_repo = git_same_core::types::Repo::test("vault", "acme"); private_repo.private = true; let private_repo = OwnedRepo::new("acme", private_repo); diff --git a/src/tui/widgets/status_bar.rs b/crates/git-same-cli/src/tui/widgets/status_bar.rs similarity index 100% rename from src/tui/widgets/status_bar.rs rename to crates/git-same-cli/src/tui/widgets/status_bar.rs diff --git a/tests/integration_test.rs b/crates/git-same-cli/tests/integration_test.rs similarity index 98% rename from tests/integration_test.rs rename to crates/git-same-cli/tests/integration_test.rs index 45deb30..c148d96 100644 --- a/tests/integration_test.rs +++ b/crates/git-same-cli/tests/integration_test.rs @@ -6,15 +6,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; fn git_same_binary() -> PathBuf { - std::env::var_os("CARGO_BIN_EXE_git_same") - .map(PathBuf::from) - .unwrap_or_else(|| { - let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - path.push("target/debug/git-same"); - #[cfg(target_os = "windows")] - path.set_extension("exe"); - path - }) + PathBuf::from(env!("CARGO_BIN_EXE_git-same")) } fn command_with_temp_env(home: &Path) -> Command { diff --git a/crates/git-same-core/Cargo.toml b/crates/git-same-core/Cargo.toml new file mode 100644 index 0000000..596f24c --- /dev/null +++ b/crates/git-same-core/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "git-same-core" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +keywords = ["git", "github", "library"] +categories = ["development-tools"] +description = "Core engine for git-same: discovery, clone, sync, IPC, status." + +[features] +# Exposes test helpers (e.g. Repo::test) for downstream crates' integration tests. +test-utils = [] + +[dependencies] +tokio = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +indicatif = { workspace = true } +console = { workspace = true } +directories = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +shellexpand = { workspace = true } +async-trait = { workspace = true } +chrono = { workspace = true } +futures = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +notify = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-foundation = { version = "0.3", features = ["NSData", "NSString"] } +objc2-app-kit = { version = "0.3", features = ["NSImage", "NSWorkspace"] } + +[dev-dependencies] +tokio-test = { workspace = true } +mockito = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/git-same-core/assets/workspace-folder.icns b/crates/git-same-core/assets/workspace-folder.icns new file mode 100644 index 0000000..20681aa Binary files /dev/null and b/crates/git-same-core/assets/workspace-folder.icns differ diff --git a/crates/git-same-core/src/api/ambient_upgrade_cache.rs b/crates/git-same-core/src/api/ambient_upgrade_cache.rs new file mode 100644 index 0000000..5d36670 --- /dev/null +++ b/crates/git-same-core/src/api/ambient_upgrade_cache.rs @@ -0,0 +1,47 @@ +//! In-memory cache of full-status entries for ambient (non-workspace) repos. +//! +//! Ambient repos start with `Badge::Gray`. When the user right-clicks a gray +//! repo, the extension sends `REFRESH /path` over the socket. The monitor then +//! runs a full `scan_repo` for that path and stores the result here. On every +//! subsequent `scan_all`, ambient entries found in this cache are emitted with +//! their full semantic badge instead of reverting to gray. +//! +//! The cache is not persisted to disk: it lives only for the current monitor +//! run. Restarting the monitor returns all ambient repos to gray until the user +//! opens their context menus again. + +use crate::types::finder_status::FinderRepoStatus; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Default)] +pub struct AmbientUpgradeCache { + inner: Arc>>, +} + +impl AmbientUpgradeCache { + pub fn new() -> Self { + Self::default() + } + + pub fn get(&self, path: &Path) -> Option { + self.inner.lock().ok()?.get(path).cloned() + } + + pub fn set(&self, path: PathBuf, status: FinderRepoStatus) { + if let Ok(mut guard) = self.inner.lock() { + guard.insert(path, status); + } + } + + pub fn remove(&self, path: &Path) { + if let Ok(mut guard) = self.inner.lock() { + guard.remove(path); + } + } +} + +#[cfg(test)] +#[path = "ambient_upgrade_cache_tests.rs"] +mod tests; diff --git a/crates/git-same-core/src/api/ambient_upgrade_cache_tests.rs b/crates/git-same-core/src/api/ambient_upgrade_cache_tests.rs new file mode 100644 index 0000000..ac77381 --- /dev/null +++ b/crates/git-same-core/src/api/ambient_upgrade_cache_tests.rs @@ -0,0 +1,66 @@ +use super::*; +use crate::types::finder_status::Badge; +use std::path::PathBuf; + +fn sample_status(path: &str, badge: Badge) -> FinderRepoStatus { + FinderRepoStatus { + path: PathBuf::from(path), + workspace: None, + org: None, + badge, + current_branch: "main".to_string(), + default_branch: None, + commit_count: 0, + staged_count: 0, + unstaged_count: 0, + untracked_count: 0, + ahead: 0, + behind: 0, + stash_count: 0, + has_important_ignored_files: false, + important_ignored_files: Vec::new(), + branches: Vec::new(), + all_branches_synced: true, + remotes: Vec::new(), + worktrees: Vec::new(), + all_worktrees_synced: true, + read_error: None, + } +} + +#[test] +fn set_then_get_returns_stored_entry() { + let cache = AmbientUpgradeCache::new(); + let path = PathBuf::from("/tmp/repo-a"); + cache.set(path.clone(), sample_status("/tmp/repo-a", Badge::Green)); + + let got = cache.get(&path).unwrap(); + assert_eq!(got.badge, Badge::Green); + assert_eq!(got.path, path); +} + +#[test] +fn get_missing_path_returns_none() { + let cache = AmbientUpgradeCache::new(); + assert!(cache.get(&PathBuf::from("/tmp/not-there")).is_none()); +} + +#[test] +fn remove_drops_entry() { + let cache = AmbientUpgradeCache::new(); + let path = PathBuf::from("/tmp/repo-b"); + cache.set(path.clone(), sample_status("/tmp/repo-b", Badge::Red)); + cache.remove(&path); + assert!(cache.get(&path).is_none()); +} + +#[test] +fn clone_shares_storage() { + let cache = AmbientUpgradeCache::new(); + let handle = cache.clone(); + let path = PathBuf::from("/tmp/shared"); + handle.set(path.clone(), sample_status("/tmp/shared", Badge::Orange)); + + // Original handle sees the write. + assert!(cache.get(&path).is_some()); +} diff --git a/crates/git-same-core/src/api/mod.rs b/crates/git-same-core/src/api/mod.rs new file mode 100644 index 0000000..d26b3ab --- /dev/null +++ b/crates/git-same-core/src/api/mod.rs @@ -0,0 +1,22 @@ +//! Public API layer for repository status scanning. +//! +//! This module exposes the `RepoScanService` — the core service used by +//! the monitor, CLI, and any future frontend (HTTP server, native app) to +//! scan repositories and compute badge status. +//! +//! ## Architecture +//! +//! The service sits between: +//! - **Consumers** (monitor loop, CLI `status` command, socket REFRESH handler) +//! - **Implementations** (`GitOperations` trait for git, `Config` for workspace layout) +//! +//! Consumers hold a `&RepoScanService` and call `scan_all()`, `scan_workspace()`, +//! or `scan_repo()` to get structured `FinderStatus` / `FinderRepoStatus` values. + +pub mod ambient_upgrade_cache; +pub mod owner_type_cache; +pub mod service; + +pub use ambient_upgrade_cache::AmbientUpgradeCache; +pub use owner_type_cache::OwnerTypeCache; +pub use service::RepoScanService; diff --git a/crates/git-same-core/src/api/owner_type_cache.rs b/crates/git-same-core/src/api/owner_type_cache.rs new file mode 100644 index 0000000..08fdaa5 --- /dev/null +++ b/crates/git-same-core/src/api/owner_type_cache.rs @@ -0,0 +1,88 @@ +//! File-backed cache of GitHub owner classifications. +//! +//! Used by the Finder badge monitor so that `OrgFolderInfo.owner_type` can be +//! populated without hitting the GitHub API on every scan. + +use crate::errors::Result; +use crate::types::OwnerType; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +/// JSON-backed map of `name -> OwnerType`. +#[derive(Clone)] +pub struct OwnerTypeCache { + path: PathBuf, + inner: Arc>>, +} + +impl OwnerTypeCache { + /// Creates a new cache at the given path and loads existing entries if + /// the file exists. Missing or unreadable files yield an empty cache + /// without error: classification is best-effort. + pub fn load(path: PathBuf) -> Self { + let map = std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default(); + Self { + path, + inner: Arc::new(Mutex::new(map)), + } + } + + /// Returns the cached owner type, or `None` if not yet classified. + /// + /// Recovers from a poisoned mutex (a prior panic while holding the lock) + /// rather than giving up: the cache holds best-effort classifications, so + /// the stored data is still safe to read. + pub fn get(&self, name: &str) -> Option { + self.inner + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(name) + .copied() + } + + /// Inserts or updates a cache entry and persists to disk. + pub fn set(&self, name: &str, owner_type: OwnerType) -> Result<()> { + { + let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + guard.insert(name.to_string(), owner_type); + } + self.persist() + } + + /// Names with no entry in the cache (targets for classification). + pub fn missing<'a>(&self, names: impl IntoIterator) -> Vec { + let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + names + .into_iter() + .filter(|n| !guard.contains_key(*n)) + .map(|n| n.to_string()) + .collect() + } + + fn persist(&self) -> Result<()> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + let snapshot: HashMap = + self.inner.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let tmp = self.path.with_extension("json.tmp"); + let data = serde_json::to_vec_pretty(&snapshot) + .map_err(|e| std::io::Error::other(format!("serialize cache: {e}")))?; + std::fs::write(&tmp, data)?; + std::fs::rename(&tmp, &self.path)?; + Ok(()) + } + + /// Default path under the Finder IPC directory. + pub fn default_path(finder_dir: &Path) -> PathBuf { + finder_dir.join("owner_types.json") + } +} + +#[cfg(test)] +#[path = "owner_type_cache_tests.rs"] +mod tests; diff --git a/crates/git-same-core/src/api/owner_type_cache_tests.rs b/crates/git-same-core/src/api/owner_type_cache_tests.rs new file mode 100644 index 0000000..6574c8f --- /dev/null +++ b/crates/git-same-core/src/api/owner_type_cache_tests.rs @@ -0,0 +1,56 @@ +use super::*; +use tempfile::TempDir; + +#[test] +fn load_empty_when_file_missing() { + let dir = TempDir::new().unwrap(); + let cache = OwnerTypeCache::load(dir.path().join("owner_types.json")); + assert!(cache.get("nobody").is_none()); +} + +#[test] +fn set_and_get_roundtrips() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("owner_types.json"); + let cache = OwnerTypeCache::load(path.clone()); + cache.set("alice", OwnerType::User).unwrap(); + cache.set("acme", OwnerType::Organization).unwrap(); + assert_eq!(cache.get("alice"), Some(OwnerType::User)); + assert_eq!(cache.get("acme"), Some(OwnerType::Organization)); + + let reloaded = OwnerTypeCache::load(path); + assert_eq!(reloaded.get("alice"), Some(OwnerType::User)); + assert_eq!(reloaded.get("acme"), Some(OwnerType::Organization)); +} + +#[test] +fn missing_returns_unknown_names() { + let dir = TempDir::new().unwrap(); + let cache = OwnerTypeCache::load(dir.path().join("owner_types.json")); + cache.set("known", OwnerType::User).unwrap(); + let todo = cache.missing(["known", "unseen-a", "unseen-b"]); + assert_eq!(todo, vec!["unseen-a".to_string(), "unseen-b".to_string()]); +} + +#[test] +fn operations_recover_from_poisoned_mutex() { + use std::sync::Arc; + + let dir = TempDir::new().unwrap(); + let cache = OwnerTypeCache::load(dir.path().join("owner_types.json")); + cache.set("alice", OwnerType::User).unwrap(); + + // Poison the mutex by panicking while holding the lock. + let inner = Arc::clone(&cache.inner); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = inner.lock().unwrap(); + panic!("intentional poison"); + })); + assert!(inner.is_poisoned(), "mutex should be poisoned"); + + // Every public operation must still work despite the poison. + assert_eq!(cache.get("alice"), Some(OwnerType::User)); + assert_eq!(cache.missing(["alice", "bob"]), vec!["bob".to_string()]); + cache.set("bob", OwnerType::Organization).unwrap(); + assert_eq!(cache.get("bob"), Some(OwnerType::Organization)); +} diff --git a/crates/git-same-core/src/api/service.rs b/crates/git-same-core/src/api/service.rs new file mode 100644 index 0000000..103fcf7 --- /dev/null +++ b/crates/git-same-core/src/api/service.rs @@ -0,0 +1,509 @@ +//! Repository scanning service. +//! +//! `RepoScanService` is the API for scanning repositories and computing badge +//! status. It owns no state — callers construct it with references to a git +//! backend and a config, then invoke `scan_all()`, `scan_workspace()`, or +//! `scan_repo()`. + +use crate::api::{AmbientUpgradeCache, OwnerTypeCache}; +use crate::config::{Config, WorkspaceConfig, WorkspaceStore}; +use crate::discovery::{find_git_repos, DiscoveryOrchestrator}; +use crate::errors::Result; +use crate::git::GitOperations; +use crate::types::finder_status::{ + compute_badge, matches_important_pattern, Badge, FinderBranchInfo, FinderRemoteInfo, + FinderRepoStatus, FinderStatus, FinderWorkspaceInfo, FinderWorktreeInfo, OrgFolderInfo, + OwnerType, DEFAULT_IMPORTANT_IGNORED_PATTERNS, +}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use tracing::debug; + +/// Service that scans repositories and computes badge status. +/// +/// This is the core API. The monitor, CLI, and any future frontend +/// (HTTP server, native app) use this to get repository status. +pub struct RepoScanService<'a> { + git: &'a dyn GitOperations, + config: &'a Config, + owner_types: Option, + ambient_upgrades: Option, +} + +impl<'a> RepoScanService<'a> { + /// Create a new service bound to a git backend and config. + pub fn new(git: &'a dyn GitOperations, config: &'a Config) -> Self { + Self { + git, + config, + owner_types: None, + ambient_upgrades: None, + } + } + + /// Attach an owner-type cache so scanned org folders are annotated with + /// `OwnerType::User` / `OwnerType::Organization`. + pub fn with_owner_types(mut self, cache: OwnerTypeCache) -> Self { + self.owner_types = Some(cache); + self + } + + /// Attach an ambient-upgrade cache so previously-upgraded ambient repos + /// keep their semantic color across periodic rescans. + pub fn with_ambient_upgrades(mut self, cache: AmbientUpgradeCache) -> Self { + self.ambient_upgrades = Some(cache); + self + } + + /// Clone the attached owner-type cache handle, if any, so socket-handler + /// tasks can reuse the same cache. + pub fn owner_types_clone(&self) -> Option { + self.owner_types.clone() + } + + /// Clone the attached ambient-upgrade cache handle, if any, so socket-handler + /// tasks can reuse it. + pub fn ambient_upgrades_clone(&self) -> Option { + self.ambient_upgrades.clone() + } + + /// Scan all workspaces and build a complete `FinderStatus`. + /// + /// Used by: monitor loop, REFRESH_ALL socket command. + pub fn scan_all(&self, pid: u32) -> Result { + let timestamp = chrono::Utc::now().to_rfc3339(); + let mut status = FinderStatus::new(pid, timestamp); + + for ws_path in &self.config.workspaces { + let expanded = shellexpand::tilde(ws_path).to_string(); + let root = PathBuf::from(&expanded); + if !root.exists() { + debug!(path = %root.display(), "Workspace root does not exist, skipping"); + continue; + } + + // Load workspace config + let ws_config = match WorkspaceStore::load(&root) { + Ok(ws) => ws, + Err(e) => { + debug!( + path = %root.display(), + error = %e, + "Failed to load workspace config, skipping" + ); + continue; + } + }; + + let base_path = ws_config.expanded_base_path(); + // Use directory name as workspace name + let ws_name = base_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(ws_path) + .to_string(); + + // orgs is Vec directly + let org_names: Vec = ws_config.orgs.clone(); + + status.workspaces.push(FinderWorkspaceInfo { + name: ws_name.clone(), + root: base_path.clone(), + orgs: org_names.clone(), + }); + + // Add org folder entries — scan filesystem for org directories + // If orgs list is specified, use it; otherwise discover from directory listing + let org_dirs: Vec = if org_names.is_empty() { + std::fs::read_dir(&base_path) + .ok() + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)) + .filter(|e| { + e.file_name() + .to_str() + .map(|n| !n.starts_with('.')) + .unwrap_or(false) + }) + .filter_map(|e| e.file_name().into_string().ok()) + .collect() + }) + .unwrap_or_default() + } else { + org_names.clone() + }; + + // Include the configured `username` alongside the orgs list so the + // user's own GitHub login gets a folder entry (and a "U" badge) even + // if it isn't in the org allowlist. + let mut owner_dirs: Vec<(String, OwnerType)> = org_dirs + .iter() + .map(|n| (n.clone(), OwnerType::Unknown)) + .collect(); + if !ws_config.username.is_empty() + && !owner_dirs.iter().any(|(n, _)| n == &ws_config.username) + { + owner_dirs.push((ws_config.username.clone(), OwnerType::User)); + } + + for (owner_name, known_type) in &owner_dirs { + let owner_path = base_path.join(owner_name); + if owner_path.exists() { + let cached = self.owner_types.as_ref().and_then(|c| c.get(owner_name)); + let owner_type = cached.unwrap_or(*known_type); + status.org_folders.push(OrgFolderInfo { + path: owner_path, + org: owner_name.clone(), + workspace: ws_name.clone(), + owner_type, + }); + } + } + + // Scan local repos in this workspace + let repos = self.scan_workspace_repos(&ws_config, Some(&ws_name)); + status.repos.extend(repos); + } + + self.populate_ambient(&mut status); + + Ok(status) + } + + /// Append ambient (non-workspace) repos and populate `monitored_roots`. + /// + /// Monitored roots = workspace roots ∪ `finder.scan_roots`. The extension + /// uses this union as its `FIFinderSyncController.directoryURLs`. + fn populate_ambient(&self, status: &mut FinderStatus) { + // Publish boot-volume aliases so the sandboxed extension can map + // alias-presented Finder paths to canonical keys with pure string + // ops (no filesystem access). Always set, independent of ambient + // mode, since workspace roots can also be browsed through the alias. + status.boot_volume_aliases = detect_boot_volume_aliases(); + + // Always publish workspace roots so the extension can register them. + for ws in &status.workspaces { + if !status.monitored_roots.contains(&ws.root) { + status.monitored_roots.push(ws.root.clone()); + } + } + + if !self.config.finder.show_ambient { + return; + } + + let scan_roots: Vec = self + .config + .finder + .scan_roots + .iter() + .map(|s| shellexpand::tilde(s).to_string()) + .map(PathBuf::from) + .filter(|p| p.exists()) + .collect(); + + for root in &scan_roots { + let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.clone()); + if !status.monitored_roots.contains(&canonical) { + status.monitored_roots.push(canonical); + } + } + + let exclude: HashSet = self.config.finder.exclude_dirs.iter().cloned().collect(); + let ambient_paths = find_git_repos(&scan_roots, self.config.finder.max_depth, &exclude); + + // Dedupe against already-emitted workspace repos (canonical form). + let workspace_paths: HashSet = status + .repos + .iter() + .map(|r| std::fs::canonicalize(&r.path).unwrap_or_else(|_| r.path.clone())) + .collect(); + + for path in ambient_paths { + if workspace_paths.contains(&path) { + continue; + } + + // Upgraded ambient repos stay upgraded until the monitor exits + // or the repo disappears. Evict stale entries for vanished repos + // in an explicit branch so the cache lookup itself has no hidden + // side effect. + let cached = match &self.ambient_upgrades { + Some(cache) if !path.join(".git").exists() => { + cache.remove(&path); + None + } + Some(cache) => cache.get(&path), + None => None, + }; + let entry = cached.unwrap_or_else(|| self.scan_ambient_repo(&path)); + + status.repos.push(entry); + } + } + + /// Build a minimal `FinderRepoStatus` for an ambient (non-workspace) repo. + /// + /// Intentionally performs zero git I/O: the user only needs to *spot* the + /// repo. Full status is computed on demand when the right-click menu + /// triggers a `REFRESH /path`. + pub fn scan_ambient_repo(&self, path: &Path) -> FinderRepoStatus { + FinderRepoStatus { + path: path.to_path_buf(), + workspace: None, + org: None, + badge: Badge::Gray, + current_branch: String::new(), + default_branch: None, + commit_count: 0, + staged_count: 0, + unstaged_count: 0, + untracked_count: 0, + ahead: 0, + behind: 0, + stash_count: 0, + has_important_ignored_files: false, + important_ignored_files: Vec::new(), + branches: Vec::new(), + all_branches_synced: true, + remotes: Vec::new(), + worktrees: Vec::new(), + all_worktrees_synced: true, + read_error: None, + } + } + + /// Scan a single workspace and return its repos with full `FinderRepoStatus`. + /// + /// Used by: CLI `status` command. + pub fn scan_workspace(&self, workspace: &WorkspaceConfig) -> Result> { + let base_path = workspace.expanded_base_path(); + let ws_name = base_path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.to_string()); + + Ok(self.scan_workspace_repos(workspace, ws_name.as_deref())) + } + + /// Internal: scan all repos discovered inside a single workspace. + fn scan_workspace_repos( + &self, + workspace: &WorkspaceConfig, + workspace_name: Option<&str>, + ) -> Vec { + let base_path = workspace.expanded_base_path(); + let structure = workspace + .structure + .as_deref() + .unwrap_or(&self.config.structure); + + let orchestrator = + DiscoveryOrchestrator::new(workspace.filters.clone(), structure.to_string()); + let local_repos = orchestrator.scan_local(&base_path, self.git); + + local_repos + .into_iter() + .map(|(repo_path, org, _name)| self.scan_repo(&repo_path, workspace_name, Some(&org))) + .collect() + } + + /// Scan a single repository and build its `FinderRepoStatus`. + /// + /// Used by: REFRESH /path socket command; internally by `scan_workspace_repos`. + pub fn scan_repo( + &self, + repo_path: &Path, + workspace: Option<&str>, + org: Option<&str>, + ) -> FinderRepoStatus { + let git = self.git; + + // Get basic status. If `git status` fails, capture the error so the + // CLI can warn the user and we can force the badge to Gray instead + // of letting the all-zero defaults masquerade as a clean repo. + let (repo_status, read_error) = match git.status(repo_path) { + Ok(s) => (s, None), + Err(e) => ( + crate::git::RepoStatus { + branch: "unknown".to_string(), + is_uncommitted: false, + ahead: 0, + behind: 0, + has_untracked: false, + staged_count: 0, + unstaged_count: 0, + untracked_count: 0, + }, + Some(e.to_string()), + ), + }; + + // Get branches + let branches: Vec = git + .list_branches(repo_path) + .unwrap_or_default() + .into_iter() + .map(|b| FinderBranchInfo { + name: b.name, + upstream: b.upstream, + ahead: b.ahead, + behind: b.behind, + synced: b.is_synced, + }) + .collect(); + + let all_branches_synced = branches.iter().all(|b| b.synced); + + // Get remotes + let remotes: Vec = git + .list_remotes(repo_path) + .unwrap_or_default() + .into_iter() + .map(|r| FinderRemoteInfo { + name: r.name, + url: r.fetch_url, + }) + .collect(); + + // Get worktrees + let worktree_infos = git.list_worktrees(repo_path).unwrap_or_default(); + let mut worktrees = Vec::new(); + let mut all_worktrees_synced = true; + + for wt in &worktree_infos { + // Skip the main worktree (same as repo_path) + if wt.path == repo_path { + continue; + } + // Check worktree status + let wt_synced = if wt.is_bare || wt.is_detached { + true + } else { + git.status(&wt.path) + .map(|s| s.is_clean_and_synced()) + .unwrap_or(false) + }; + if !wt_synced { + all_worktrees_synced = false; + } + worktrees.push(FinderWorktreeInfo { + path: wt.path.clone(), + branch: wt.branch.clone(), + synced: wt_synced, + }); + } + + // Get commit count + let commit_count = git.commit_count(repo_path).unwrap_or(0); + + // Get stash count + let stash_count = git.stash_count(repo_path).unwrap_or(0); + + // Check for important ignored files (only if otherwise clean) + let is_otherwise_clean = repo_status.staged_count == 0 + && repo_status.unstaged_count == 0 + && repo_status.untracked_count == 0 + && repo_status.ahead == 0 + && all_branches_synced + && all_worktrees_synced; + + let (has_important_ignored_files, important_ignored_files) = if is_otherwise_clean { + self.check_important_ignored(repo_path) + } else { + (false, Vec::new()) + }; + + // Compute badge. Unreadable repos stay Gray so they don't pose as + // healthy Green repos in the Finder or in `gisa status`. + let badge = if read_error.is_some() { + Badge::Gray + } else { + compute_badge( + repo_status.staged_count, + repo_status.unstaged_count, + repo_status.untracked_count, + repo_status.ahead, + all_branches_synced, + all_worktrees_synced, + has_important_ignored_files, + ) + }; + + FinderRepoStatus { + path: repo_path.to_path_buf(), + workspace: workspace.map(|s| s.to_string()), + org: org.map(|s| s.to_string()), + badge, + current_branch: repo_status.branch, + default_branch: None, + commit_count, + staged_count: repo_status.staged_count, + unstaged_count: repo_status.unstaged_count, + untracked_count: repo_status.untracked_count, + ahead: repo_status.ahead, + behind: repo_status.behind, + stash_count, + has_important_ignored_files, + important_ignored_files, + branches, + all_branches_synced, + remotes, + worktrees, + all_worktrees_synced, + read_error, + } + } + + /// Check if a repo has important ignored files matching the configured patterns. + fn check_important_ignored(&self, repo_path: &Path) -> (bool, Vec) { + let ignored_files = match self.git.list_ignored_files(repo_path) { + Ok(files) => files, + Err(_) => return (false, Vec::new()), + }; + + let patterns = DEFAULT_IMPORTANT_IGNORED_PATTERNS; + let important: Vec = ignored_files + .into_iter() + .filter(|f| matches_important_pattern(f, patterns)) + .collect(); + + let has_any = !important.is_empty(); + (has_any, important) + } +} + +/// Detect boot-volume aliases: `/Volumes/` entries that are symlinks +/// pointing at the root volume `/`. macOS auto-creates one of these so Finder +/// can show the boot volume by name; folders browsed through it keep the +/// `/Volumes/` prefix in their URL. The monitor (non-sandboxed) reads +/// `/Volumes` and publishes the prefixes so the sandboxed extension can do +/// alias→canonical mapping with pure string ops instead of touching the disk. +/// +/// Returns an empty vec on any I/O error (the common no-alias case). +fn detect_boot_volume_aliases() -> Vec { + let entries = match std::fs::read_dir("/Volumes") { + Ok(entries) => entries, + Err(_) => return Vec::new(), + }; + let mut aliases = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + // Only symlinks whose target is exactly the root volume "/". + match std::fs::read_link(&path) { + Ok(target) if target == Path::new("/") => { + if let Some(s) = path.to_str() { + aliases.push(s.to_string()); + } + } + _ => {} + } + } + aliases +} + +#[cfg(test)] +#[path = "service_tests.rs"] +mod tests; diff --git a/crates/git-same-core/src/api/service_tests.rs b/crates/git-same-core/src/api/service_tests.rs new file mode 100644 index 0000000..fe21cb4 --- /dev/null +++ b/crates/git-same-core/src/api/service_tests.rs @@ -0,0 +1,251 @@ +use super::*; +use crate::api::AmbientUpgradeCache; +use crate::config::Config; +use crate::git::traits::mock::{MockConfig, MockGit}; +use crate::git::traits::RepoStatus; +use crate::types::finder_status::{Badge, FinderRepoStatus}; + +fn default_config() -> Config { + // Tests should not trigger the ambient $HOME walk, so disable it unless + // a specific test opts in. + let mut cfg = Config::default(); + cfg.finder.show_ambient = false; + cfg +} + +#[test] +fn test_scan_repo_clean() { + let mock = MockGit::new(); + let config = default_config(); + let service = RepoScanService::new(&mock, &config); + + let status = service.scan_repo(Path::new("/tmp/repo"), Some("ws"), Some("org")); + + assert_eq!(status.badge, Badge::Green); + assert_eq!(status.current_branch, "main"); + assert_eq!(status.staged_count, 0); + assert_eq!(status.unstaged_count, 0); + assert_eq!(status.workspace, Some("ws".to_string())); + assert_eq!(status.org, Some("org".to_string())); + assert!(status.all_branches_synced); +} + +#[test] +fn test_scan_repo_dirty() { + let mock_cfg = MockConfig { + default_status: RepoStatus { + branch: "feature".to_string(), + is_uncommitted: true, + ahead: 2, + behind: 0, + has_untracked: true, + staged_count: 1, + unstaged_count: 3, + untracked_count: 2, + }, + ..Default::default() + }; + let mock = MockGit::with_config(mock_cfg); + let config = default_config(); + let service = RepoScanService::new(&mock, &config); + + let status = service.scan_repo(Path::new("/tmp/repo"), None, None); + + assert_eq!(status.badge, Badge::Red); + assert_eq!(status.current_branch, "feature"); + assert_eq!(status.staged_count, 1); + assert_eq!(status.unstaged_count, 3); + assert_eq!(status.untracked_count, 2); + assert_eq!(status.ahead, 2); +} + +#[test] +fn scan_repo_captures_read_error_and_forces_gray_badge() { + // A repo whose `git status` fails must be flagged via `read_error` so the + // CLI can warn the user. Crucially the badge must NOT be Green; that is + // the original regression that caused unreadable repos to roll into + // "All repositories are clean and up to date". + let mock_cfg = MockConfig { + fail_status_paths: vec!["/tmp/broken-repo".to_string()], + error_message: Some("fatal: not a git repository".to_string()), + ..Default::default() + }; + let mock = MockGit::with_config(mock_cfg); + let config = default_config(); + let service = RepoScanService::new(&mock, &config); + + let status = service.scan_repo(Path::new("/tmp/broken-repo"), Some("ws"), Some("org")); + + assert!( + status.read_error.is_some(), + "scan_repo should surface git.status errors via read_error" + ); + assert_eq!( + status.badge, + Badge::Gray, + "unreadable repos must not show as Badge::Green; that was the original regression" + ); +} + +#[test] +fn test_scan_repo_no_workspace() { + let mock = MockGit::new(); + let config = default_config(); + let service = RepoScanService::new(&mock, &config); + + let status = service.scan_repo(Path::new("/tmp/repo"), None, None); + + assert!(status.workspace.is_none()); + assert!(status.org.is_none()); +} + +#[test] +fn test_check_important_ignored_none() { + let mock = MockGit::new(); + let config = default_config(); + let service = RepoScanService::new(&mock, &config); + + let (has, files) = service.check_important_ignored(Path::new("/tmp/repo")); + assert!(!has); + assert!(files.is_empty()); +} + +#[test] +fn test_scan_all_empty_workspaces() { + let mock = MockGit::new(); + let config = default_config(); + let service = RepoScanService::new(&mock, &config); + + let status = service.scan_all(12345).unwrap(); + assert_eq!(status.daemon_pid, 12345); + assert!(status.workspaces.is_empty()); + assert!(status.repos.is_empty()); + assert!(status.org_folders.is_empty()); +} + +#[test] +fn scan_ambient_repo_is_minimal_and_gray() { + let mock = MockGit::new(); + let config = default_config(); + let service = RepoScanService::new(&mock, &config); + + let status = service.scan_ambient_repo(Path::new("/tmp/ambient")); + + assert_eq!(status.badge, Badge::Gray); + assert_eq!(status.path, std::path::PathBuf::from("/tmp/ambient")); + assert!(status.workspace.is_none()); + assert!(status.org.is_none()); + assert_eq!(status.staged_count, 0); + assert_eq!(status.commit_count, 0); + assert!(status.branches.is_empty()); + assert!(status.remotes.is_empty()); +} + +#[test] +fn scan_all_emits_ambient_gray_repos_when_enabled() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join("alpha/.git")).unwrap(); + std::fs::create_dir_all(tmp.path().join("beta/.git")).unwrap(); + std::fs::create_dir_all(tmp.path().join("not-a-repo")).unwrap(); + + let mock = MockGit::new(); + let mut cfg = Config::default(); + cfg.finder.show_ambient = true; + cfg.finder.scan_roots = vec![tmp.path().to_string_lossy().to_string()]; + cfg.finder.max_depth = 2; + cfg.finder.exclude_dirs = Vec::new(); + + let service = RepoScanService::new(&mock, &cfg); + let status = service.scan_all(1).unwrap(); + + let gray_count = status + .repos + .iter() + .filter(|r| r.badge == Badge::Gray) + .count(); + assert_eq!(gray_count, 2); + assert!(status + .repos + .iter() + .any(|r| r.path.ends_with("alpha") && r.badge == Badge::Gray)); + assert!(status + .repos + .iter() + .any(|r| r.path.ends_with("beta") && r.badge == Badge::Gray)); + let canonical_tmp = std::fs::canonicalize(tmp.path()).unwrap(); + assert!(status.monitored_roots.iter().any(|p| p == &canonical_tmp)); +} + +#[test] +fn ambient_upgrade_cache_preserves_semantic_color() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join("myrepo/.git")).unwrap(); + let repo_path = std::fs::canonicalize(tmp.path().join("myrepo")).unwrap(); + + let mock = MockGit::new(); + let mut cfg = Config::default(); + cfg.finder.show_ambient = true; + cfg.finder.scan_roots = vec![tmp.path().to_string_lossy().to_string()]; + cfg.finder.max_depth = 2; + cfg.finder.exclude_dirs = Vec::new(); + + let upgrades = AmbientUpgradeCache::new(); + // Prime the cache with a Green upgraded entry. + let upgraded = FinderRepoStatus { + path: repo_path.clone(), + workspace: None, + org: None, + badge: Badge::Green, + current_branch: "main".to_string(), + default_branch: None, + commit_count: 42, + staged_count: 0, + unstaged_count: 0, + untracked_count: 0, + ahead: 0, + behind: 0, + stash_count: 0, + has_important_ignored_files: false, + important_ignored_files: Vec::new(), + branches: Vec::new(), + all_branches_synced: true, + remotes: Vec::new(), + worktrees: Vec::new(), + all_worktrees_synced: true, + read_error: None, + }; + upgrades.set(repo_path.clone(), upgraded); + + let service = RepoScanService::new(&mock, &cfg).with_ambient_upgrades(upgrades); + let status = service.scan_all(1).unwrap(); + + let emitted = status + .repos + .iter() + .find(|r| std::fs::canonicalize(&r.path).unwrap_or(r.path.clone()) == repo_path) + .expect("ambient repo should be emitted"); + assert_eq!(emitted.badge, Badge::Green); + assert_eq!(emitted.commit_count, 42); +} + +#[test] +fn test_detect_boot_volume_aliases_invariants() { + // Environment-independent: on CI there may be no boot-volume alias, on a + // Mac there is usually exactly one. Either way the reader must never panic + // and every entry must be a non-duplicate `/Volumes/` prefix whose + // symlink really resolves to `/`. + let aliases = detect_boot_volume_aliases(); + let mut seen = std::collections::HashSet::new(); + for alias in &aliases { + assert!( + alias.starts_with("/Volumes/"), + "alias must be under /Volumes: {alias}" + ); + assert!(seen.insert(alias.clone()), "duplicate alias: {alias}"); + assert_eq!( + std::fs::read_link(alias).ok(), + Some(std::path::PathBuf::from("/")), + "alias must be a symlink to /: {alias}" + ); + } +} diff --git a/src/auth/gh_cli.rs b/crates/git-same-core/src/auth/gh_cli.rs similarity index 74% rename from src/auth/gh_cli.rs rename to crates/git-same-core/src/auth/gh_cli.rs index 81cff3b..80b72fd 100644 --- a/src/auth/gh_cli.rs +++ b/crates/git-same-core/src/auth/gh_cli.rs @@ -2,33 +2,37 @@ //! //! Uses the `gh` CLI tool to obtain authentication tokens securely. +use crate::auth::process::run_with_timeout; use crate::errors::AppError; -use std::process::Command; +use std::process::Output; +use std::time::Duration; + +/// Maximum time to wait for any `gh` subprocess to complete. +pub(crate) const GH_COMMAND_TIMEOUT: Duration = Duration::from_secs(10); + +/// Run a `gh` subcommand with a hard timeout, mapping I/O errors to `AppError`. +fn run_gh_with_timeout(args: &[&str]) -> Result { + run_with_timeout("gh", args, GH_COMMAND_TIMEOUT) + .map_err(|e| AppError::auth(format!("'gh {}' failed: {}", args.join(" "), e))) +} /// Check if the GitHub CLI is installed. pub fn is_installed() -> bool { - Command::new("gh") - .arg("--version") - .output() + run_gh_with_timeout(&["--version"]) .map(|o| o.status.success()) .unwrap_or(false) } /// Check if the user is authenticated with the GitHub CLI. pub fn is_authenticated() -> bool { - Command::new("gh") - .args(["auth", "status"]) - .output() + run_gh_with_timeout(&["auth", "status"]) .map(|o| o.status.success()) .unwrap_or(false) } /// Get the authentication token from the GitHub CLI. pub fn get_token() -> Result { - let output = Command::new("gh") - .args(["auth", "token"]) - .output() - .map_err(|e| AppError::auth(format!("Failed to run 'gh auth token': {}", e)))?; + let output = run_gh_with_timeout(&["auth", "token"])?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -52,10 +56,7 @@ pub fn get_token() -> Result { /// Get the authenticated GitHub username. pub fn get_username() -> Result { - let output = Command::new("gh") - .args(["api", "user", "--jq", ".login"]) - .output() - .map_err(|e| AppError::auth(format!("Failed to get username from gh: {}", e)))?; + let output = run_gh_with_timeout(&["api", "user", "--jq", ".login"])?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -79,15 +80,7 @@ pub fn get_username() -> Result { /// Get token for a specific GitHub host (for GitHub Enterprise). pub fn get_token_for_host(host: &str) -> Result { - let output = Command::new("gh") - .args(["auth", "token", "--hostname", host]) - .output() - .map_err(|e| { - AppError::auth(format!( - "Failed to run 'gh auth token --hostname {}': {}", - host, e - )) - })?; + let output = run_gh_with_timeout(&["auth", "token", "--hostname", host])?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/src/auth/gh_cli_tests.rs b/crates/git-same-core/src/auth/gh_cli_tests.rs similarity index 92% rename from src/auth/gh_cli_tests.rs rename to crates/git-same-core/src/auth/gh_cli_tests.rs index 6d13e62..01944f4 100644 --- a/src/auth/gh_cli_tests.rs +++ b/crates/git-same-core/src/auth/gh_cli_tests.rs @@ -1,5 +1,10 @@ use super::*; +#[test] +fn test_gh_command_timeout_is_ten_seconds() { + assert_eq!(GH_COMMAND_TIMEOUT.as_secs(), 10); +} + #[test] fn test_is_installed_returns_bool() { // This test just verifies the function runs without panicking diff --git a/src/auth/mod.rs b/crates/git-same-core/src/auth/mod.rs similarity index 99% rename from src/auth/mod.rs rename to crates/git-same-core/src/auth/mod.rs index 4b01265..ed06f08 100644 --- a/src/auth/mod.rs +++ b/crates/git-same-core/src/auth/mod.rs @@ -4,6 +4,7 @@ //! using the GitHub CLI (`gh auth token`). pub mod gh_cli; +pub(crate) mod process; pub mod ssh; use crate::config::WorkspaceProvider; diff --git a/src/auth/mod_tests.rs b/crates/git-same-core/src/auth/mod_tests.rs similarity index 100% rename from src/auth/mod_tests.rs rename to crates/git-same-core/src/auth/mod_tests.rs diff --git a/crates/git-same-core/src/auth/process.rs b/crates/git-same-core/src/auth/process.rs new file mode 100644 index 0000000..e83c493 --- /dev/null +++ b/crates/git-same-core/src/auth/process.rs @@ -0,0 +1,53 @@ +//! Subprocess helpers shared by auth probes. +//! +//! The `gh` CLI and the SSH probe both shell out to external binaries that +//! can stall on network or credential issues. This module provides a single +//! polling-based timeout helper so neither call can block the async runtime +//! or the TUI event loop indefinitely. + +use std::io; +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +/// Run `program` with `args` and a hard wall-clock timeout. +/// +/// On timeout, the child process is killed and reaped, and an +/// [`io::ErrorKind::TimedOut`] error is returned. +pub(crate) fn run_with_timeout( + program: &str, + args: &[&str], + timeout: Duration, +) -> io::Result { + let mut child = Command::new(program) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let deadline = Instant::now() + timeout; + loop { + match child.try_wait()? { + Some(_) => return child.wait_with_output(), + None => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "'{} {}' timed out after {}s", + program, + args.join(" "), + timeout.as_secs() + ), + )); + } + std::thread::sleep(Duration::from_millis(50)); + } + } + } +} + +#[cfg(test)] +#[path = "process_tests.rs"] +mod tests; diff --git a/crates/git-same-core/src/auth/process_tests.rs b/crates/git-same-core/src/auth/process_tests.rs new file mode 100644 index 0000000..821822a --- /dev/null +++ b/crates/git-same-core/src/auth/process_tests.rs @@ -0,0 +1,31 @@ +use super::*; + +#[test] +fn returns_output_for_fast_command() { + let output = + run_with_timeout("true", &[], Duration::from_secs(2)).expect("fast command should succeed"); + assert!(output.status.success()); +} + +#[test] +fn returns_error_for_missing_binary() { + let err = run_with_timeout( + "definitely-not-a-real-binary-xyz", + &[], + Duration::from_secs(1), + ) + .expect_err("missing binary should fail"); + assert_eq!(err.kind(), io::ErrorKind::NotFound); +} + +#[test] +fn times_out_and_kills_slow_command() { + let start = Instant::now(); + let err = run_with_timeout("sleep", &["5"], Duration::from_millis(200)) + .expect_err("slow command should time out"); + assert_eq!(err.kind(), io::ErrorKind::TimedOut); + assert!( + start.elapsed() < Duration::from_secs(2), + "timeout did not kill the child quickly enough" + ); +} diff --git a/src/auth/ssh.rs b/crates/git-same-core/src/auth/ssh.rs similarity index 76% rename from src/auth/ssh.rs rename to crates/git-same-core/src/auth/ssh.rs index e6c8ba3..5be99fd 100644 --- a/src/auth/ssh.rs +++ b/crates/git-same-core/src/auth/ssh.rs @@ -4,8 +4,17 @@ //! NOT GitHub API calls. This module detects if SSH keys are configured //! so we can provide better error messages and suggest SSH clone URLs. +use crate::auth::process::run_with_timeout; +use std::io; use std::path::PathBuf; -use std::process::Command; +use std::time::Duration; + +/// Maximum wall-clock time to wait for the SSH probe subprocess. +/// +/// The SSH `ConnectTimeout=5` option only guards the TCP handshake; the +/// authentication exchange that follows can still stall (e.g. on an +/// unresponsive agent). This is an outer safety net that kills the process. +pub(crate) const SSH_PROBE_TIMEOUT: Duration = Duration::from_secs(10); /// Outcome of probing SSH connectivity to GitHub. #[derive(Debug, Clone, PartialEq, Eq)] @@ -48,22 +57,23 @@ fn parse_ssh_probe_output(stderr: &str) -> SshProbeResult { /// Probe SSH connectivity to GitHub and return a diagnostic result. /// -/// Uses BatchMode to avoid interactive prompts. ConnectTimeout=5 prevents -/// hanging on network issues. +/// Uses BatchMode to avoid interactive prompts. `ConnectTimeout=5` guards +/// the TCP connect; [`SSH_PROBE_TIMEOUT`] is an outer wall-clock limit that +/// kills the process if the subsequent handshake stalls. pub fn probe_github_ssh() -> SshProbeResult { - let output = Command::new("ssh") - .args([ - "-T", - "-o", - "BatchMode=yes", - "-o", - "ConnectTimeout=5", - "git@github.com", - ]) - .output(); - - match output { + let args = [ + "-T", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=5", + "git@github.com", + ]; + + match run_with_timeout("ssh", &args, SSH_PROBE_TIMEOUT) { Ok(o) => parse_ssh_probe_output(&String::from_utf8_lossy(&o.stderr)), + Err(e) if e.kind() == io::ErrorKind::TimedOut => SshProbeResult::ConnectionTimeout, + Err(e) if e.kind() == io::ErrorKind::NotFound => SshProbeResult::SshNotFound, Err(_) => SshProbeResult::SshNotFound, } } diff --git a/src/auth/ssh_tests.rs b/crates/git-same-core/src/auth/ssh_tests.rs similarity index 100% rename from src/auth/ssh_tests.rs rename to crates/git-same-core/src/auth/ssh_tests.rs diff --git a/src/cache/discovery.rs b/crates/git-same-core/src/cache/discovery.rs similarity index 100% rename from src/cache/discovery.rs rename to crates/git-same-core/src/cache/discovery.rs diff --git a/src/cache/discovery_tests.rs b/crates/git-same-core/src/cache/discovery_tests.rs similarity index 100% rename from src/cache/discovery_tests.rs rename to crates/git-same-core/src/cache/discovery_tests.rs diff --git a/src/cache/mod.rs b/crates/git-same-core/src/cache/mod.rs similarity index 78% rename from src/cache/mod.rs rename to crates/git-same-core/src/cache/mod.rs index b49f7cc..2124e29 100644 --- a/src/cache/mod.rs +++ b/crates/git-same-core/src/cache/mod.rs @@ -1,9 +1,7 @@ //! Cache and history persistence. mod discovery; -#[cfg(feature = "tui")] mod sync_history; pub use discovery::{CacheManager, DiscoveryCache, CACHE_VERSION}; -#[cfg(feature = "tui")] pub use sync_history::SyncHistoryManager; diff --git a/src/cache/sync_history.rs b/crates/git-same-core/src/cache/sync_history.rs similarity index 98% rename from src/cache/sync_history.rs rename to crates/git-same-core/src/cache/sync_history.rs index 6f2140c..d15ec71 100644 --- a/src/cache/sync_history.rs +++ b/crates/git-same-core/src/cache/sync_history.rs @@ -4,7 +4,7 @@ use std::fs; use std::path::{Path, PathBuf}; use tracing::debug; -use crate::tui::app::SyncHistoryEntry; +use crate::types::SyncHistoryEntry; const HISTORY_VERSION: u32 = 1; const MAX_HISTORY_ENTRIES: usize = 50; diff --git a/src/cache/sync_history_tests.rs b/crates/git-same-core/src/cache/sync_history_tests.rs similarity index 100% rename from src/cache/sync_history_tests.rs rename to crates/git-same-core/src/cache/sync_history_tests.rs diff --git a/src/checks.rs b/crates/git-same-core/src/checks.rs similarity index 100% rename from src/checks.rs rename to crates/git-same-core/src/checks.rs diff --git a/src/checks_tests.rs b/crates/git-same-core/src/checks_tests.rs similarity index 100% rename from src/checks_tests.rs rename to crates/git-same-core/src/checks_tests.rs diff --git a/src/config/mod.rs b/crates/git-same-core/src/config/mod.rs similarity index 100% rename from src/config/mod.rs rename to crates/git-same-core/src/config/mod.rs diff --git a/src/config/parser.rs b/crates/git-same-core/src/config/parser.rs similarity index 68% rename from src/config/parser.rs rename to crates/git-same-core/src/config/parser.rs index c0a8cf2..d38c3a1 100644 --- a/src/config/parser.rs +++ b/crates/git-same-core/src/config/parser.rs @@ -46,6 +46,136 @@ pub struct FilterOptions { pub exclude_repos: Vec, } +/// Monitor process configuration. +/// +/// Controls behavior of the long-running monitor (`gisa monitor`) that +/// periodically rescans every workspace and ambient root, then writes +/// `status.json` for the Finder extension to consume. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MonitorConfig { + /// Seconds between full rescans. The CLI flag `--interval` overrides this. + #[serde(default = "default_fullscan_interval_secs")] + pub fullscan_interval_secs: u64, +} + +impl Default for MonitorConfig { + fn default() -> Self { + Self { + fullscan_interval_secs: default_fullscan_interval_secs(), + } + } +} + +fn default_fullscan_interval_secs() -> u64 { + 30 +} + +/// Finder-badge discovery configuration. +/// +/// Controls how the monitor finds ambient git repositories outside any +/// configured workspace. Ambient repos get a neutral gray badge until the +/// user opens their context menu, which triggers an on-demand upgrade. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FinderConfig { + /// Roots to walk when looking for ambient git repos. Defaults to `["~"]` + /// (the user's home directory). + #[serde(default = "default_finder_scan_roots")] + pub scan_roots: Vec, + + /// Maximum directory depth to descend during the ambient walk. + #[serde(default = "default_finder_max_depth")] + pub max_depth: usize, + + /// Directory names to skip during the ambient walk. Load-bearing for + /// performance: without entries like `node_modules` and `target`, the + /// scan times balloon on developer machines. + #[serde(default = "default_finder_excludes")] + pub exclude_dirs: Vec, + + /// Feature flag: when true, the monitor adds `scan_roots` to the watched + /// directory set so ambient repos can be badged. Defaults to false because + /// the default `scan_roots = ["~"]` expands to the user's home directory, + /// and macOS silently refuses to deliver `requestBadgeIdentifier` calls to + /// FinderSync extensions whose `directoryURLs` contain the home folder — + /// which would suppress badges for the configured workspaces too. Opt in + /// only after narrowing `scan_roots` to specific subdirectories. + #[serde(default = "default_false")] + pub show_ambient: bool, +} + +impl Default for FinderConfig { + fn default() -> Self { + Self { + scan_roots: default_finder_scan_roots(), + max_depth: default_finder_max_depth(), + exclude_dirs: default_finder_excludes(), + show_ambient: false, + } + } +} + +fn default_finder_scan_roots() -> Vec { + vec!["~".to_string()] +} + +fn default_finder_max_depth() -> usize { + 8 +} + +fn default_finder_excludes() -> Vec { + vec![ + "node_modules".into(), + "target".into(), + "build".into(), + "dist".into(), + "DerivedData".into(), + "Pods".into(), + "Library".into(), + ".cache".into(), + ".cargo".into(), + ".rustup".into(), + ".npm".into(), + ".yarn".into(), + ".venv".into(), + ".Trash".into(), + ".git-same".into(), + ".zsh_sessions".into(), + ] +} + +fn default_false() -> bool { + false +} + +fn default_true() -> bool { + true +} + +/// UI / Finder-presentation configuration. Currently controls the macOS +/// "paint the Git-Same logo on workspace root folders" feature, which uses +/// `NSWorkspace.setIcon` to write an `Icon\r` resource into each workspace +/// root so Finder shows the icon in sidebar, column, list, icon, and Get Info +/// views (similar to how Synology Drive paints its "D" onto synced folders). +/// +/// On non-macOS targets the flag is parsed but has no effect — the underlying +/// API only exists on macOS. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UiConfig { + /// Paint the Git-Same workspace folder icon onto each workspace root on + /// setup and reapply it on every monitor loop. Stripped on workspace + /// removal (`gisa reset`). Default: true. + #[serde(default = "default_true")] + pub custom_folder_icon: bool, +} + +impl Default for UiConfig { + fn default() -> Self { + Self { + custom_folder_icon: true, + } + } +} + /// Sync mode for existing repositories. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "kebab-case")] @@ -105,6 +235,18 @@ pub struct Config { /// Registry of known workspace root paths (tilde-collapsed). #[serde(default)] pub workspaces: Vec, + + /// Finder badge monitor configuration (ambient repo discovery). + #[serde(default)] + pub finder: FinderConfig, + + /// Monitor process configuration (scan cadence, etc.). + #[serde(default)] + pub monitor: MonitorConfig, + + /// UI / Finder-presentation configuration (custom folder icon, etc.). + #[serde(default)] + pub ui: UiConfig, } fn default_structure() -> String { @@ -130,6 +272,9 @@ impl Default for Config { clone: ConfigCloneOptions::default(), filters: FilterOptions::default(), workspaces: Vec::new(), + finder: FinderConfig::default(), + monitor: MonitorConfig::default(), + ui: UiConfig::default(), } } } @@ -250,6 +395,46 @@ include_forks = false # Exclude specific repos # exclude_repos = ["org/repo-to-skip"] + +[finder] +# Show a neutral gray R-badge on every git repository found under +# `scan_roots`, even outside a configured workspace. Right-clicking a gray +# repo upgrades it to the normal color (green/blue/orange/red). +# +# Disabled by default because the default `scan_roots = ["~"]` would put the +# user's home folder into the FinderSync extension's watched URL set, and +# macOS silently refuses to deliver badge requests to extensions that watch +# the home directory — that suppresses badges everywhere, including the +# configured workspaces. Narrow `scan_roots` to specific subdirectories +# before enabling this. +show_ambient = false + +# Roots to walk for ambient repos. "~" expands to your home directory. +# Only used when `show_ambient = true`. Don't leave this as `["~"]` if you +# enable show_ambient — see the note above. +scan_roots = ["~"] + +# Maximum directory depth for the ambient walk. +max_depth = 8 + +# Directory names skipped during the ambient walk. Critical for performance. +exclude_dirs = [ + "node_modules", "target", "build", "dist", "DerivedData", "Pods", + "Library", ".cache", ".cargo", ".rustup", ".npm", ".yarn", ".venv", + ".Trash", ".git-same", ".zsh_sessions", +] + +[monitor] +# Seconds between full rescans by the background monitor. The CLI flag +# `gisa monitor --interval N` overrides this when set explicitly. +fullscan_interval_secs = 30 + +[ui] +# Paint the Git-Same logo onto each workspace root folder so Finder shows it +# in the sidebar, column, list, icon, and Get Info views (similar to how +# Synology Drive marks its synced folders). Stripped automatically on +# `gisa reset`. macOS only — the flag is parsed but ignored on Linux/Windows. +custom_folder_icon = true "# } diff --git a/src/config/parser_tests.rs b/crates/git-same-core/src/config/parser_tests.rs similarity index 75% rename from src/config/parser_tests.rs rename to crates/git-same-core/src/config/parser_tests.rs index 1b82064..a8f1a44 100644 --- a/src/config/parser_tests.rs +++ b/crates/git-same-core/src/config/parser_tests.rs @@ -235,3 +235,89 @@ workspaces = "invalid" let content = std::fs::read_to_string(&path).unwrap(); assert!(content.contains(r#"workspaces = "invalid""#)); } + +#[test] +fn finder_config_defaults() { + let cfg = FinderConfig::default(); + assert_eq!(cfg.scan_roots, vec!["~".to_string()]); + assert_eq!(cfg.max_depth, 8); + assert!(!cfg.show_ambient); + assert!(cfg.exclude_dirs.iter().any(|s| s == "node_modules")); + assert!(cfg.exclude_dirs.iter().any(|s| s == "target")); +} + +#[test] +fn finder_config_parses_from_toml() { + let content = r#" +[finder] +show_ambient = false +max_depth = 3 +scan_roots = ["/tmp/repos"] +exclude_dirs = ["custom_skip"] +"#; + let cfg = Config::parse(content).unwrap(); + assert!(!cfg.finder.show_ambient); + assert_eq!(cfg.finder.max_depth, 3); + assert_eq!(cfg.finder.scan_roots, vec!["/tmp/repos".to_string()]); + assert_eq!(cfg.finder.exclude_dirs, vec!["custom_skip".to_string()]); +} + +#[test] +fn finder_config_missing_section_uses_defaults() { + let cfg = Config::parse("concurrency = 4").unwrap(); + assert!(!cfg.finder.show_ambient); + assert_eq!(cfg.finder.max_depth, 8); +} + +#[test] +fn monitor_config_defaults() { + let cfg = MonitorConfig::default(); + assert_eq!(cfg.fullscan_interval_secs, 30); +} + +#[test] +fn monitor_config_parses_from_toml() { + let content = r#" +[monitor] +fullscan_interval_secs = 120 +"#; + let cfg = Config::parse(content).unwrap(); + assert_eq!(cfg.monitor.fullscan_interval_secs, 120); +} + +#[test] +fn monitor_config_missing_section_uses_defaults() { + let cfg = Config::parse("concurrency = 4").unwrap(); + assert_eq!(cfg.monitor.fullscan_interval_secs, 30); +} + +#[test] +fn default_toml_includes_monitor_section() { + let toml = Config::default_toml(); + assert!(toml.contains("[monitor]")); + assert!(toml.contains("fullscan_interval_secs = 30")); + let cfg = Config::parse(&toml).unwrap(); + assert_eq!(cfg.monitor.fullscan_interval_secs, 30); +} + +#[test] +fn ui_config_defaults_to_custom_folder_icon_on() { + let cfg = Config::default(); + assert!(cfg.ui.custom_folder_icon); +} + +#[test] +fn ui_config_explicit_false_overrides_default() { + let content = r#" +[ui] +custom_folder_icon = false +"#; + let cfg = Config::parse(content).unwrap(); + assert!(!cfg.ui.custom_folder_icon); +} + +#[test] +fn ui_config_missing_section_uses_default() { + let cfg = Config::parse("concurrency = 4").unwrap(); + assert!(cfg.ui.custom_folder_icon); +} diff --git a/src/config/provider_config.rs b/crates/git-same-core/src/config/provider_config.rs similarity index 100% rename from src/config/provider_config.rs rename to crates/git-same-core/src/config/provider_config.rs diff --git a/src/config/provider_config_tests.rs b/crates/git-same-core/src/config/provider_config_tests.rs similarity index 100% rename from src/config/provider_config_tests.rs rename to crates/git-same-core/src/config/provider_config_tests.rs diff --git a/src/config/workspace.rs b/crates/git-same-core/src/config/workspace.rs similarity index 100% rename from src/config/workspace.rs rename to crates/git-same-core/src/config/workspace.rs diff --git a/src/config/workspace_manager.rs b/crates/git-same-core/src/config/workspace_manager.rs similarity index 100% rename from src/config/workspace_manager.rs rename to crates/git-same-core/src/config/workspace_manager.rs diff --git a/src/config/workspace_manager_tests.rs b/crates/git-same-core/src/config/workspace_manager_tests.rs similarity index 100% rename from src/config/workspace_manager_tests.rs rename to crates/git-same-core/src/config/workspace_manager_tests.rs diff --git a/src/config/workspace_policy.rs b/crates/git-same-core/src/config/workspace_policy.rs similarity index 100% rename from src/config/workspace_policy.rs rename to crates/git-same-core/src/config/workspace_policy.rs diff --git a/src/config/workspace_policy_tests.rs b/crates/git-same-core/src/config/workspace_policy_tests.rs similarity index 100% rename from src/config/workspace_policy_tests.rs rename to crates/git-same-core/src/config/workspace_policy_tests.rs diff --git a/src/config/workspace_store.rs b/crates/git-same-core/src/config/workspace_store.rs similarity index 100% rename from src/config/workspace_store.rs rename to crates/git-same-core/src/config/workspace_store.rs diff --git a/src/config/workspace_store_tests.rs b/crates/git-same-core/src/config/workspace_store_tests.rs similarity index 100% rename from src/config/workspace_store_tests.rs rename to crates/git-same-core/src/config/workspace_store_tests.rs diff --git a/src/config/workspace_tests.rs b/crates/git-same-core/src/config/workspace_tests.rs similarity index 100% rename from src/config/workspace_tests.rs rename to crates/git-same-core/src/config/workspace_tests.rs diff --git a/src/discovery.rs b/crates/git-same-core/src/discovery.rs similarity index 71% rename from src/discovery.rs rename to crates/git-same-core/src/discovery.rs index e226464..89faa07 100644 --- a/src/discovery.rs +++ b/crates/git-same-core/src/discovery.rs @@ -13,7 +13,7 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; /// Mutable context for directory scanning (keeps `scan_dir` under Clippy’s argument limit). -struct ScanDirContext<'a, G: GitOperations> { +struct ScanDirContext<'a, G: GitOperations + ?Sized> { base_path: &'a Path, git: &'a G, repos: &'a mut Vec<(PathBuf, String, String)>, @@ -137,7 +137,7 @@ impl DiscoveryOrchestrator { } /// Scans local filesystem for cloned repositories. - pub fn scan_local( + pub fn scan_local( &self, base_path: &Path, git: &G, @@ -164,7 +164,7 @@ impl DiscoveryOrchestrator { } /// Recursively scans directories for git repos. - fn scan_dir( + fn scan_dir( &self, path: &Path, current_depth: usize, @@ -232,6 +232,113 @@ impl DiscoveryOrchestrator { } } +/// Walks the given roots and returns every git repository root found. +/// +/// A directory is a repo if it contains a `.git` entry (directory for normal +/// repos, or a file for worktree/submodule gitlinks). Once a repo is found we +/// stop descending into it — we only care about repo roots, not files inside. +/// +/// - `max_depth` caps recursion depth (the root itself is depth 0). +/// - `exclude` is matched against directory **names** (not full paths), so +/// passing `"node_modules"` skips every `node_modules/` no matter where it +/// sits. Pass lowercase for case-insensitive matching if needed; current +/// behaviour is exact match. +/// - Symlinks and cycles are handled via a visited-set of canonical paths. +/// - Permission-denied or I/O errors are silently skipped. +/// +/// Returns canonical paths in the order they were discovered, deduplicated. +pub fn find_git_repos( + roots: &[PathBuf], + max_depth: usize, + exclude: &HashSet, +) -> Vec { + let mut found: Vec = Vec::new(); + let mut found_set: HashSet = HashSet::new(); + let mut visited: HashSet = HashSet::new(); + + for root in roots { + let Ok(canonical_root) = std::fs::canonicalize(root) else { + continue; + }; + walk_for_repos( + &canonical_root, + 0, + max_depth, + exclude, + &mut found, + &mut found_set, + &mut visited, + ); + } + + found +} + +fn walk_for_repos( + path: &Path, + depth: usize, + max_depth: usize, + exclude: &HashSet, + found: &mut Vec, + found_set: &mut HashSet, + visited: &mut HashSet, +) { + if !visited.insert(path.to_path_buf()) { + return; + } + + // A repo is identified by the presence of a `.git` entry (dir or file). + // `symlink_metadata` is cheaper than `metadata` and doesn't follow links. + if std::fs::symlink_metadata(path.join(".git")).is_ok() { + if found_set.insert(path.to_path_buf()) { + found.push(path.to_path_buf()); + } + return; // don't descend into a repo's own tree + } + + if depth >= max_depth { + return; + } + + let Ok(entries) = std::fs::read_dir(path) else { + return; + }; + + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + // Skip excluded names and hidden dirs (hidden dirs rarely contain + // repos we care about; `.git-same`, `.Trash` etc. already excluded + // by name list but this is a belt-and-braces). + if exclude.contains(name_str.as_ref()) { + continue; + } + if name_str.starts_with('.') { + continue; + } + + let child = entry.path(); + let canonical_child = std::fs::canonicalize(&child).unwrap_or(child); + walk_for_repos( + &canonical_child, + depth + 1, + max_depth, + exclude, + found, + found_set, + visited, + ); + } +} + /// Merges discovered repos from multiple providers. pub fn merge_repos(repos_by_provider: Vec<(String, Vec)>) -> Vec<(String, OwnedRepo)> { let mut result = Vec::new(); diff --git a/src/discovery_tests.rs b/crates/git-same-core/src/discovery_tests.rs similarity index 66% rename from src/discovery_tests.rs rename to crates/git-same-core/src/discovery_tests.rs index 14fb682..07f685a 100644 --- a/src/discovery_tests.rs +++ b/crates/git-same-core/src/discovery_tests.rs @@ -193,3 +193,97 @@ fn test_to_discovery_options() { assert!(!options.include_forks); assert_eq!(options.org_filter, vec!["org1", "org2"]); } + +fn touch(path: &Path) { + std::fs::write(path, "").unwrap(); +} + +fn make_repo(dir: &Path) { + std::fs::create_dir_all(dir.join(".git")).unwrap(); +} + +#[test] +fn find_git_repos_detects_root_and_nested_repos() { + let tmp = TempDir::new().unwrap(); + make_repo(&tmp.path().join("top")); + make_repo(&tmp.path().join("nested/inner")); + std::fs::create_dir_all(tmp.path().join("not-a-repo")).unwrap(); + + let roots = vec![tmp.path().to_path_buf()]; + let exclude: HashSet = HashSet::new(); + let found = find_git_repos(&roots, 5, &exclude); + + assert!(found.iter().any(|p| p.ends_with("top"))); + assert!(found.iter().any(|p| p.ends_with("inner"))); + assert!(!found.iter().any(|p| p.ends_with("not-a-repo"))); +} + +#[test] +fn find_git_repos_stops_descending_inside_a_repo() { + let tmp = TempDir::new().unwrap(); + make_repo(&tmp.path().join("outer")); + // A nested ".git" inside an already-detected repo should NOT produce a + // second hit — we stop descending as soon as we see a repo root. + make_repo(&tmp.path().join("outer/sub")); + + let found = find_git_repos(&[tmp.path().to_path_buf()], 5, &HashSet::new()); + + let outer_hits = found + .iter() + .filter(|p| p.ends_with("outer") || p.ends_with("sub")) + .count(); + assert_eq!(outer_hits, 1); +} + +#[test] +fn find_git_repos_honors_exclude_list() { + let tmp = TempDir::new().unwrap(); + make_repo(&tmp.path().join("node_modules/leaky-lib")); + make_repo(&tmp.path().join("keep-me")); + + let mut exclude = HashSet::new(); + exclude.insert("node_modules".to_string()); + + let found = find_git_repos(&[tmp.path().to_path_buf()], 5, &exclude); + + assert!(found.iter().any(|p| p.ends_with("keep-me"))); + assert!(!found.iter().any(|p| p.ends_with("leaky-lib"))); +} + +#[test] +fn find_git_repos_respects_max_depth() { + let tmp = TempDir::new().unwrap(); + make_repo(&tmp.path().join("a/b/c/deep-repo")); + + // Depth 2 means we can descend "a" → "b" but not into "c". + let found = find_git_repos(&[tmp.path().to_path_buf()], 2, &HashSet::new()); + assert!(found.is_empty()); + + // Depth 4 reaches it. + let found = find_git_repos(&[tmp.path().to_path_buf()], 4, &HashSet::new()); + assert!(found.iter().any(|p| p.ends_with("deep-repo"))); +} + +#[test] +fn find_git_repos_handles_gitlink_file() { + // Submodule/worktree gitlink: `.git` is a regular file, not a directory. + let tmp = TempDir::new().unwrap(); + let submodule = tmp.path().join("submodule"); + std::fs::create_dir_all(&submodule).unwrap(); + touch(&submodule.join(".git")); + + let found = find_git_repos(&[tmp.path().to_path_buf()], 3, &HashSet::new()); + assert!(found.iter().any(|p| p.ends_with("submodule"))); +} + +#[test] +fn find_git_repos_skips_hidden_directories() { + let tmp = TempDir::new().unwrap(); + make_repo(&tmp.path().join(".hidden/repo")); + + let found = find_git_repos(&[tmp.path().to_path_buf()], 5, &HashSet::new()); + assert!( + !found.iter().any(|p| p.ends_with("repo")), + "hidden dirs should not be traversed" + ); +} diff --git a/src/domain/mod.rs b/crates/git-same-core/src/domain/mod.rs similarity index 100% rename from src/domain/mod.rs rename to crates/git-same-core/src/domain/mod.rs diff --git a/src/domain/repo_path_template.rs b/crates/git-same-core/src/domain/repo_path_template.rs similarity index 100% rename from src/domain/repo_path_template.rs rename to crates/git-same-core/src/domain/repo_path_template.rs diff --git a/src/domain/repo_path_template_tests.rs b/crates/git-same-core/src/domain/repo_path_template_tests.rs similarity index 100% rename from src/domain/repo_path_template_tests.rs rename to crates/git-same-core/src/domain/repo_path_template_tests.rs diff --git a/src/errors/app.rs b/crates/git-same-core/src/errors/app.rs similarity index 100% rename from src/errors/app.rs rename to crates/git-same-core/src/errors/app.rs diff --git a/src/errors/app_tests.rs b/crates/git-same-core/src/errors/app_tests.rs similarity index 100% rename from src/errors/app_tests.rs rename to crates/git-same-core/src/errors/app_tests.rs diff --git a/src/errors/git.rs b/crates/git-same-core/src/errors/git.rs similarity index 100% rename from src/errors/git.rs rename to crates/git-same-core/src/errors/git.rs diff --git a/src/errors/git_tests.rs b/crates/git-same-core/src/errors/git_tests.rs similarity index 100% rename from src/errors/git_tests.rs rename to crates/git-same-core/src/errors/git_tests.rs diff --git a/src/errors/mod.rs b/crates/git-same-core/src/errors/mod.rs similarity index 91% rename from src/errors/mod.rs rename to crates/git-same-core/src/errors/mod.rs index 72660ff..8fa8f57 100644 --- a/src/errors/mod.rs +++ b/crates/git-same-core/src/errors/mod.rs @@ -8,7 +8,7 @@ //! # Example //! //! ``` -//! use git_same::errors::{AppError, Result}; +//! use git_same_core::errors::{AppError, Result}; //! //! fn do_something() -> Result<()> { //! Err(AppError::config("missing required field")) diff --git a/src/errors/provider.rs b/crates/git-same-core/src/errors/provider.rs similarity index 100% rename from src/errors/provider.rs rename to crates/git-same-core/src/errors/provider.rs diff --git a/src/errors/provider_tests.rs b/crates/git-same-core/src/errors/provider_tests.rs similarity index 100% rename from src/errors/provider_tests.rs rename to crates/git-same-core/src/errors/provider_tests.rs diff --git a/src/git/mod.rs b/crates/git-same-core/src/git/mod.rs similarity index 94% rename from src/git/mod.rs rename to crates/git-same-core/src/git/mod.rs index c119b45..cf2af6a 100644 --- a/src/git/mod.rs +++ b/crates/git-same-core/src/git/mod.rs @@ -13,7 +13,7 @@ //! # Example //! //! ```no_run -//! use git_same::git::{ShellGit, GitOperations, CloneOptions}; +//! use git_same_core::git::{ShellGit, GitOperations, CloneOptions}; //! use std::path::Path; //! //! let git = ShellGit::new(); diff --git a/src/git/mod_tests.rs b/crates/git-same-core/src/git/mod_tests.rs similarity index 100% rename from src/git/mod_tests.rs rename to crates/git-same-core/src/git/mod_tests.rs diff --git a/src/git/shell.rs b/crates/git-same-core/src/git/shell.rs similarity index 61% rename from src/git/shell.rs rename to crates/git-same-core/src/git/shell.rs index 7dd2d71..fd06c0a 100644 --- a/src/git/shell.rs +++ b/crates/git-same-core/src/git/shell.rs @@ -4,7 +4,10 @@ //! by invoking git commands through the shell. use crate::errors::GitError; -use crate::git::traits::{CloneOptions, FetchResult, GitOperations, PullResult, RepoStatus}; +use crate::git::traits::{ + BranchInfo, CloneOptions, FetchResult, GitOperations, PullResult, RemoteInfo, RepoStatus, + WorktreeInfo, +}; use std::path::Path; use std::process::{Command, Output}; use tracing::{debug, trace}; @@ -107,6 +110,61 @@ impl ShellGit { } } + /// Parses the %(upstream:track) format from for-each-ref. + /// Format: "[ahead N]", "[behind N]", "[ahead N, behind M]", or "" (synced/no upstream). + fn parse_track_info(track: &str) -> (u32, u32) { + let mut ahead = 0; + let mut behind = 0; + if let Some(start) = track.find('[') { + if let Some(end) = track.find(']') { + let content = &track[start + 1..end]; + for part in content.split(", ") { + if let Some(n) = part.strip_prefix("ahead ") { + ahead = n.parse().unwrap_or(0); + } else if let Some(n) = part.strip_prefix("behind ") { + behind = n.parse().unwrap_or(0); + } + } + } + } + (ahead, behind) + } + + /// Parses a single line of `for-each-ref` output in the format used by + /// `list_branches`: `\t\t`. + /// Returns `None` if the line has no branch name. Pure function so it + /// can be unit-tested without shelling out. + fn parse_branch_line(line: &str) -> Option { + let parts: Vec<&str> = line.splitn(3, '\t').collect(); + let name = parts.first().unwrap_or(&"").to_string(); + if name.is_empty() { + return None; + } + + let upstream_raw = parts.get(1).unwrap_or(&"").to_string(); + let track = parts.get(2).unwrap_or(&""); + let (ahead, behind) = Self::parse_track_info(track); + // `for-each-ref` emits "[gone]" in %(upstream:track) when the + // upstream ref has been deleted. %(upstream:short) still returns + // the dead ref name, so without this check is_synced would be + // true for a branch with no reachable upstream. + let upstream_gone = track.trim() == "[gone]"; + let upstream = if upstream_raw.is_empty() || upstream_gone { + None + } else { + Some(upstream_raw) + }; + let is_synced = upstream.is_some() && ahead == 0 && behind == 0; + + Some(BranchInfo { + name, + upstream, + ahead, + behind, + is_synced, + }) + } + /// Parses branch info from git status -b --porcelain output. fn parse_branch_info(&self, output: &str) -> (String, u32, u32) { let first_line = output.lines().next().unwrap_or(""); @@ -355,6 +413,161 @@ impl GitOperations for ShellGit { } Ok(output.lines().map(|l| l.to_string()).collect()) } + + fn list_branches(&self, repo_path: &Path) -> Result, GitError> { + // Use for-each-ref to get branch name, upstream, and tracking status in one call + let output = self.run_git_output( + &[ + "for-each-ref", + "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)", + "refs/heads/", + ], + Some(repo_path), + )?; + + Ok(output + .lines() + .filter(|l| !l.is_empty()) + .filter_map(Self::parse_branch_line) + .collect()) + } + + fn list_remotes(&self, repo_path: &Path) -> Result, GitError> { + let output = self.run_git_output(&["remote", "-v"], Some(repo_path))?; + if output.is_empty() { + return Ok(Vec::new()); + } + + // git remote -v outputs pairs: "origin\turl (fetch)" and "origin\turl (push)" + // Collect into a map keyed by remote name + let mut remotes: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + + for line in output.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 3 { + continue; + } + let name = parts[0].to_string(); + let url = parts[1].to_string(); + let kind = parts[2]; // "(fetch)" or "(push)" + + let entry = remotes + .entry(name) + .or_insert_with(|| (String::new(), String::new())); + if kind == "(fetch)" { + entry.0 = url; + } else if kind == "(push)" { + entry.1 = url; + } + } + + Ok(remotes + .into_iter() + .map(|(name, (fetch_url, push_url))| RemoteInfo { + name, + fetch_url: fetch_url.clone(), + push_url: if push_url.is_empty() { + fetch_url + } else { + push_url + }, + }) + .collect()) + } + + fn list_worktrees(&self, repo_path: &Path) -> Result, GitError> { + let output = self.run_git_output(&["worktree", "list", "--porcelain"], Some(repo_path))?; + if output.is_empty() { + return Ok(Vec::new()); + } + + // Porcelain format: blocks separated by blank lines + // Each block: "worktree \nHEAD \nbranch refs/heads/\n" + // Or: "worktree \nHEAD \ndetached\n" + // Or: "worktree \nbare\n" + let mut worktrees = Vec::new(); + let mut current_path: Option = None; + let mut current_branch: Option = None; + let mut is_bare = false; + let mut is_detached = false; + + for line in output.lines() { + if line.is_empty() { + // End of a worktree block + if let Some(path) = current_path.take() { + worktrees.push(WorktreeInfo { + path, + branch: current_branch.take(), + is_bare, + is_detached, + }); + } + is_bare = false; + is_detached = false; + continue; + } + + if let Some(path_str) = line.strip_prefix("worktree ") { + current_path = Some(std::path::PathBuf::from(path_str)); + } else if let Some(branch_ref) = line.strip_prefix("branch ") { + // Strip "refs/heads/" prefix to get short branch name + current_branch = Some( + branch_ref + .strip_prefix("refs/heads/") + .unwrap_or(branch_ref) + .to_string(), + ); + } else if line == "bare" { + is_bare = true; + } else if line == "detached" { + is_detached = true; + } + } + + // Handle last block (porcelain output may not end with blank line) + if let Some(path) = current_path.take() { + worktrees.push(WorktreeInfo { + path, + branch: current_branch.take(), + is_bare, + is_detached, + }); + } + + Ok(worktrees) + } + + fn commit_count(&self, repo_path: &Path) -> Result { + let output = self.run_git_output(&["rev-list", "--count", "HEAD"], Some(repo_path))?; + output.parse().map_err(|_| { + GitError::command_failed( + "git rev-list --count HEAD", + format!("Could not parse commit count: '{}'", output), + ) + }) + } + + fn stash_count(&self, repo_path: &Path) -> Result { + let output = self.run_git_output(&["stash", "list"], Some(repo_path)); + match output { + Ok(s) if s.is_empty() => Ok(0), + Ok(s) => Ok(s.lines().count()), + // git stash list returns error on repos with no stashes in some git versions + Err(_) => Ok(0), + } + } + + fn list_ignored_files(&self, repo_path: &Path) -> Result, GitError> { + let output = self.run_git_output( + &["ls-files", "--others", "--ignored", "--exclude-standard"], + Some(repo_path), + )?; + if output.is_empty() { + return Ok(Vec::new()); + } + Ok(output.lines().map(|l| l.to_string()).collect()) + } } #[cfg(test)] diff --git a/crates/git-same-core/src/git/shell_tests.rs b/crates/git-same-core/src/git/shell_tests.rs new file mode 100644 index 0000000..8597ab9 --- /dev/null +++ b/crates/git-same-core/src/git/shell_tests.rs @@ -0,0 +1,276 @@ +use super::*; + +#[test] +fn test_shell_git_creation() { + let _git = ShellGit::new(); + // ShellGit is a zero-sized type with no fields +} + +#[test] +fn test_parse_branch_info_simple() { + let git = ShellGit::new(); + let (branch, ahead, behind) = git.parse_branch_info("## main"); + assert_eq!(branch, "main"); + assert_eq!(ahead, 0); + assert_eq!(behind, 0); +} + +#[test] +fn test_parse_branch_info_with_tracking() { + let git = ShellGit::new(); + let (branch, ahead, behind) = git.parse_branch_info("## main...origin/main"); + assert_eq!(branch, "main"); + assert_eq!(ahead, 0); + assert_eq!(behind, 0); +} + +#[test] +fn test_parse_branch_info_ahead() { + let git = ShellGit::new(); + let (branch, ahead, behind) = git.parse_branch_info("## feature...origin/feature [ahead 3]"); + assert_eq!(branch, "feature"); + assert_eq!(ahead, 3); + assert_eq!(behind, 0); +} + +#[test] +fn test_parse_branch_info_behind() { + let git = ShellGit::new(); + let (branch, ahead, behind) = git.parse_branch_info("## main...origin/main [behind 5]"); + assert_eq!(branch, "main"); + assert_eq!(ahead, 0); + assert_eq!(behind, 5); +} + +#[test] +fn test_parse_branch_info_diverged() { + let git = ShellGit::new(); + let (branch, ahead, behind) = + git.parse_branch_info("## develop...origin/develop [ahead 2, behind 7]"); + assert_eq!(branch, "develop"); + assert_eq!(ahead, 2); + assert_eq!(behind, 7); +} + +#[test] +fn test_parse_status_clean() { + let git = ShellGit::new(); + let status = git.parse_status_output("", "## main...origin/main"); + assert!(!status.is_uncommitted); + assert!(!status.has_untracked); + assert_eq!(status.branch, "main"); +} + +#[test] +fn test_parse_status_modified() { + let git = ShellGit::new(); + let status = git.parse_status_output(" M src/main.rs", "## main"); + assert!(status.is_uncommitted); + assert!(!status.has_untracked); +} + +#[test] +fn test_parse_status_untracked() { + let git = ShellGit::new(); + let status = git.parse_status_output("?? newfile.txt", "## main"); + assert!(!status.is_uncommitted); + assert!(status.has_untracked); +} + +#[test] +fn test_parse_status_mixed() { + let git = ShellGit::new(); + let output = " M src/main.rs\n?? newfile.txt\nA staged.rs"; + let status = git.parse_status_output(output, "## feature [ahead 1, behind 2]"); + assert!(status.is_uncommitted); + assert!(status.has_untracked); + assert_eq!(status.branch, "feature"); + assert_eq!(status.ahead, 1); + assert_eq!(status.behind, 2); +} + +// Unit tests for parse_track_info +#[test] +fn test_parse_track_info_empty() { + let (ahead, behind) = ShellGit::parse_track_info(""); + assert_eq!(ahead, 0); + assert_eq!(behind, 0); +} + +#[test] +fn test_parse_track_info_ahead() { + let (ahead, behind) = ShellGit::parse_track_info("[ahead 5]"); + assert_eq!(ahead, 5); + assert_eq!(behind, 0); +} + +#[test] +fn test_parse_track_info_behind() { + let (ahead, behind) = ShellGit::parse_track_info("[behind 3]"); + assert_eq!(ahead, 0); + assert_eq!(behind, 3); +} + +#[test] +fn test_parse_track_info_diverged() { + let (ahead, behind) = ShellGit::parse_track_info("[ahead 2, behind 7]"); + assert_eq!(ahead, 2); + assert_eq!(behind, 7); +} + +#[test] +fn test_parse_track_info_gone_yields_zeros() { + // "[gone]" is the for-each-ref signal that the upstream ref no longer + // exists. parse_track_info doesn't decode it, but list_branches relies + // on it returning (0, 0) so the higher-level upstream-clearing logic + // can take over without spurious counts leaking through. + let (ahead, behind) = ShellGit::parse_track_info("[gone]"); + assert_eq!(ahead, 0); + assert_eq!(behind, 0); +} + +#[test] +fn test_parse_branch_line_synced() { + let info = ShellGit::parse_branch_line("main\torigin/main\t").unwrap(); + assert_eq!(info.name, "main"); + assert_eq!(info.upstream, Some("origin/main".to_string())); + assert_eq!(info.ahead, 0); + assert_eq!(info.behind, 0); + assert!(info.is_synced); +} + +#[test] +fn test_parse_branch_line_no_upstream() { + let info = ShellGit::parse_branch_line("local-only\t\t").unwrap(); + assert!(info.upstream.is_none()); + assert!(!info.is_synced); +} + +#[test] +fn test_parse_branch_line_diverged() { + let info = ShellGit::parse_branch_line("feature\torigin/feature\t[ahead 2, behind 7]").unwrap(); + assert_eq!(info.upstream, Some("origin/feature".to_string())); + assert_eq!(info.ahead, 2); + assert_eq!(info.behind, 7); + assert!(!info.is_synced); +} + +#[test] +fn test_parse_branch_line_gone_upstream_not_synced() { + // When the upstream ref has been deleted, %(upstream:short) still + // returns the now-dead name and %(upstream:track) is "[gone]". The + // branch must not be reported as synced; its commits are local-only. + let info = ShellGit::parse_branch_line("orphan\torigin/orphan\t[gone]").unwrap(); + assert!( + info.upstream.is_none(), + "[gone] upstream should be cleared so the branch isn't presented as tracking a live ref" + ); + assert!( + !info.is_synced, + "branch with deleted upstream must not be reported as synced; Badge::Green would mislead the user into deleting unique commits" + ); +} + +#[test] +fn test_parse_branch_line_empty_returns_none() { + assert!(ShellGit::parse_branch_line("").is_none()); + assert!(ShellGit::parse_branch_line("\torigin/foo\t").is_none()); +} + +// Integration tests that require actual git repo +#[test] +#[ignore] // Run with: cargo test -- --ignored +fn test_is_repo_real() { + let git = ShellGit::new(); + // Current directory should be a git repo + assert!(git.is_repo(Path::new("."))); + // Root is not a git repo + assert!(!git.is_repo(Path::new("/"))); +} + +#[test] +#[ignore] +fn test_current_branch_real() { + let git = ShellGit::new(); + let branch = git.current_branch(Path::new(".")); + assert!(branch.is_ok()); + // Should return some branch name + assert!(!branch.unwrap().is_empty()); +} + +#[test] +#[ignore] +fn test_status_real() { + let git = ShellGit::new(); + let status = git.status(Path::new(".")); + assert!(status.is_ok()); + let status = status.unwrap(); + // Should have a branch + assert!(!status.branch.is_empty()); +} + +#[test] +#[ignore] +fn test_list_branches_real() { + let git = ShellGit::new(); + let branches = git.list_branches(Path::new(".")); + assert!(branches.is_ok()); + let branches = branches.unwrap(); + // Should have at least one branch + assert!(!branches.is_empty()); + // At least one branch should have a name + assert!(!branches[0].name.is_empty()); +} + +#[test] +#[ignore] +fn test_list_remotes_real() { + let git = ShellGit::new(); + let remotes = git.list_remotes(Path::new(".")); + assert!(remotes.is_ok()); + let remotes = remotes.unwrap(); + // Should have at least one remote (origin) + assert!(!remotes.is_empty()); + assert_eq!(remotes[0].name, "origin"); + assert!(!remotes[0].fetch_url.is_empty()); +} + +#[test] +#[ignore] +fn test_list_worktrees_real() { + let git = ShellGit::new(); + let worktrees = git.list_worktrees(Path::new(".")); + assert!(worktrees.is_ok()); + let worktrees = worktrees.unwrap(); + // Should have at least the main worktree + assert!(!worktrees.is_empty()); + assert!(worktrees[0].path.exists()); +} + +#[test] +#[ignore] +fn test_commit_count_real() { + let git = ShellGit::new(); + let count = git.commit_count(Path::new(".")); + assert!(count.is_ok()); + // Should have at least 1 commit + assert!(count.unwrap() > 0); +} + +#[test] +#[ignore] +fn test_stash_count_real() { + let git = ShellGit::new(); + let count = git.stash_count(Path::new(".")); + assert!(count.is_ok()); + // Just check it doesn't error; count could be 0 +} + +#[test] +#[ignore] +fn test_list_ignored_files_real() { + let git = ShellGit::new(); + let files = git.list_ignored_files(Path::new(".")); + assert!(files.is_ok()); + // Just check it doesn't error; could be empty +} diff --git a/src/git/traits.rs b/crates/git-same-core/src/git/traits.rs similarity index 74% rename from src/git/traits.rs rename to crates/git-same-core/src/git/traits.rs index d52d325..a580dbf 100644 --- a/src/git/traits.rs +++ b/crates/git-same-core/src/git/traits.rs @@ -4,7 +4,7 @@ //! allowing for both real and mock implementations for testing. use crate::errors::GitError; -use std::path::Path; +use std::path::{Path, PathBuf}; /// Options for cloning a repository. #[derive(Debug, Clone, Default)] @@ -75,6 +75,45 @@ impl RepoStatus { } } +/// Information about a local branch and its upstream tracking status. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BranchInfo { + /// Local branch name + pub name: String, + /// Upstream tracking branch (e.g., "origin/main") + pub upstream: Option, + /// Commits ahead of upstream + pub ahead: u32, + /// Commits behind upstream + pub behind: u32, + /// Whether branch is fully synced (ahead == 0 && behind == 0 && has upstream) + pub is_synced: bool, +} + +/// Information about a git remote. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteInfo { + /// Remote name (e.g., "origin") + pub name: String, + /// Fetch URL + pub fetch_url: String, + /// Push URL (may differ from fetch URL) + pub push_url: String, +} + +/// Information about a git worktree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeInfo { + /// Absolute path to the worktree + pub path: PathBuf, + /// Branch checked out in this worktree + pub branch: Option, + /// Whether this is a bare repository + pub is_bare: bool, + /// Whether HEAD is detached + pub is_detached: bool, +} + /// Result of a fetch operation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FetchResult { @@ -152,6 +191,44 @@ pub trait GitOperations: Send + Sync { /// * `repo_path` - Path to the local repository /// * `limit` - Maximum number of commits to return fn recent_commits(&self, repo_path: &Path, limit: usize) -> Result, GitError>; + + /// Lists all local branches with their upstream tracking status. + /// + /// # Arguments + /// * `repo_path` - Path to the local repository + fn list_branches(&self, repo_path: &Path) -> Result, GitError>; + + /// Lists all configured remotes with their URLs. + /// + /// # Arguments + /// * `repo_path` - Path to the local repository + fn list_remotes(&self, repo_path: &Path) -> Result, GitError>; + + /// Lists all worktrees for a repository. + /// + /// # Arguments + /// * `repo_path` - Path to the local repository + fn list_worktrees(&self, repo_path: &Path) -> Result, GitError>; + + /// Gets the total number of commits on the current branch. + /// + /// # Arguments + /// * `repo_path` - Path to the local repository + fn commit_count(&self, repo_path: &Path) -> Result; + + /// Gets the number of stash entries. + /// + /// # Arguments + /// * `repo_path` - Path to the local repository + fn stash_count(&self, repo_path: &Path) -> Result; + + /// Lists ignored files that exist on disk. + /// + /// Returns file paths relative to the repository root. + /// + /// # Arguments + /// * `repo_path` - Path to the local repository + fn list_ignored_files(&self, repo_path: &Path) -> Result, GitError>; } /// A mock implementation of GitOperations for testing. @@ -185,6 +262,10 @@ pub mod mock { pub default_status: RepoStatus, /// Custom statuses per path pub path_statuses: HashMap, + /// Paths whose `status()` call should return an error. Used by + /// `RepoScanService` tests to verify that `read_error` is captured + /// instead of being silently coerced to a clean default. + pub fail_status_paths: Vec, /// Paths that are valid repos pub valid_repos: Vec, /// Custom error message for failures @@ -209,6 +290,7 @@ pub mod mock { untracked_count: 0, }, path_statuses: HashMap::new(), + fail_status_paths: Vec::new(), valid_repos: Vec::new(), error_message: None, } @@ -356,6 +438,16 @@ pub mod mock { let path_str = repo_path.to_string_lossy().to_string(); log.status_checks.push(path_str.clone()); + if self.config.fail_status_paths.contains(&path_str) { + return Err(GitError::command_failed( + "git status", + self.config + .error_message + .as_deref() + .unwrap_or("mock status failure"), + )); + } + if let Some(status) = self.config.path_statuses.get(&path_str) { Ok(status.clone()) } else { @@ -388,6 +480,40 @@ pub mod mock { ) -> Result, GitError> { Ok(Vec::new()) } + + fn list_branches(&self, _repo_path: &Path) -> Result, GitError> { + Ok(vec![BranchInfo { + name: self.config.default_status.branch.clone(), + upstream: Some(format!("origin/{}", self.config.default_status.branch)), + ahead: 0, + behind: 0, + is_synced: true, + }]) + } + + fn list_remotes(&self, _repo_path: &Path) -> Result, GitError> { + Ok(vec![RemoteInfo { + name: "origin".to_string(), + fetch_url: "git@github.com:example/repo.git".to_string(), + push_url: "git@github.com:example/repo.git".to_string(), + }]) + } + + fn list_worktrees(&self, _repo_path: &Path) -> Result, GitError> { + Ok(Vec::new()) + } + + fn commit_count(&self, _repo_path: &Path) -> Result { + Ok(42) + } + + fn stash_count(&self, _repo_path: &Path) -> Result { + Ok(0) + } + + fn list_ignored_files(&self, _repo_path: &Path) -> Result, GitError> { + Ok(Vec::new()) + } } } diff --git a/src/git/traits_tests.rs b/crates/git-same-core/src/git/traits_tests.rs similarity index 64% rename from src/git/traits_tests.rs rename to crates/git-same-core/src/git/traits_tests.rs index a90dcc7..80db8c4 100644 --- a/src/git/traits_tests.rs +++ b/crates/git-same-core/src/git/traits_tests.rs @@ -75,6 +75,67 @@ fn test_repo_status_can_fast_forward() { assert!(!diverged.can_fast_forward()); } +#[test] +fn test_branch_info_synced() { + let branch = BranchInfo { + name: "main".to_string(), + upstream: Some("origin/main".to_string()), + ahead: 0, + behind: 0, + is_synced: true, + }; + assert!(branch.is_synced); + assert_eq!(branch.name, "main"); +} + +#[test] +fn test_branch_info_no_upstream() { + let branch = BranchInfo { + name: "local-only".to_string(), + upstream: None, + ahead: 0, + behind: 0, + is_synced: false, + }; + assert!(!branch.is_synced); +} + +#[test] +fn test_remote_info() { + let remote = RemoteInfo { + name: "origin".to_string(), + fetch_url: "git@github.com:user/repo.git".to_string(), + push_url: "git@github.com:user/repo.git".to_string(), + }; + assert_eq!(remote.name, "origin"); + assert_eq!(remote.fetch_url, remote.push_url); +} + +#[test] +fn test_worktree_info() { + let wt = WorktreeInfo { + path: Path::new("/tmp/worktree").to_path_buf(), + branch: Some("feature".to_string()), + is_bare: false, + is_detached: false, + }; + assert_eq!(wt.branch.as_deref(), Some("feature")); + assert!(!wt.is_bare); + assert!(!wt.is_detached); +} + +#[test] +fn test_worktree_info_detached() { + let wt = WorktreeInfo { + path: Path::new("/tmp/worktree").to_path_buf(), + branch: None, + is_bare: false, + is_detached: true, + }; + assert!(wt.branch.is_none()); + assert!(wt.is_detached); +} + mod mock_tests { use super::mock::*; use super::*; @@ -171,6 +232,51 @@ mod mock_tests { assert!(!mock.is_repo(Path::new("/tmp/not-a-repo"))); } + #[test] + fn test_mock_list_branches() { + let mock = MockGit::new(); + let branches = mock.list_branches(Path::new("/tmp/repo")).unwrap(); + assert_eq!(branches.len(), 1); + assert_eq!(branches[0].name, "main"); + assert!(branches[0].is_synced); + } + + #[test] + fn test_mock_list_remotes() { + let mock = MockGit::new(); + let remotes = mock.list_remotes(Path::new("/tmp/repo")).unwrap(); + assert_eq!(remotes.len(), 1); + assert_eq!(remotes[0].name, "origin"); + } + + #[test] + fn test_mock_list_worktrees() { + let mock = MockGit::new(); + let worktrees = mock.list_worktrees(Path::new("/tmp/repo")).unwrap(); + assert!(worktrees.is_empty()); + } + + #[test] + fn test_mock_commit_count() { + let mock = MockGit::new(); + let count = mock.commit_count(Path::new("/tmp/repo")).unwrap(); + assert_eq!(count, 42); + } + + #[test] + fn test_mock_stash_count() { + let mock = MockGit::new(); + let count = mock.stash_count(Path::new("/tmp/repo")).unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn test_mock_list_ignored_files() { + let mock = MockGit::new(); + let files = mock.list_ignored_files(Path::new("/tmp/repo")).unwrap(); + assert!(files.is_empty()); + } + #[test] fn test_mock_call_log_tracking() { let mock = MockGit::new(); diff --git a/crates/git-same-core/src/infra/mod.rs b/crates/git-same-core/src/infra/mod.rs new file mode 100644 index 0000000..d3f8988 --- /dev/null +++ b/crates/git-same-core/src/infra/mod.rs @@ -0,0 +1,10 @@ +//! Legacy infrastructure facade. +//! +//! New code should import [`crate::cache`], [`crate::config`], [`crate::git`], +//! and [`crate::provider`] directly. This facade remains public for backwards +//! compatibility and is deprecated at the crate root. + +pub mod storage; + +pub use crate::git; +pub use crate::provider; diff --git a/crates/git-same-core/src/infra/storage/mod.rs b/crates/git-same-core/src/infra/storage/mod.rs new file mode 100644 index 0000000..03e7b29 --- /dev/null +++ b/crates/git-same-core/src/infra/storage/mod.rs @@ -0,0 +1,7 @@ +//! Legacy storage facade. +//! +//! New code should import cache managers from [`crate::cache`] and workspace +//! storage helpers from [`crate::config`] directly. + +pub use crate::cache::*; +pub use crate::config::workspace_manager::*; diff --git a/crates/git-same-core/src/ipc/mod.rs b/crates/git-same-core/src/ipc/mod.rs new file mode 100644 index 0000000..c4e9322 --- /dev/null +++ b/crates/git-same-core/src/ipc/mod.rs @@ -0,0 +1,126 @@ +//! IPC (Inter-Process Communication) for the monitor and Finder extension. +//! +//! This module provides cross-platform abstractions for: +//! - **Status file**: Atomic JSON writes from the monitor, read by the extension. +//! - **Socket/pipe**: Refresh requests from the extension to the monitor. +//! +//! On macOS/Linux, communication uses Unix domain sockets. +//! On Windows, named pipes are used instead. +//! +//! ## macOS path resolution +//! +//! On macOS, IPC files live in the app-group container at +//! `~/Library/Group Containers//` so the sandboxed Badges +//! extension and the (non-sandboxed) Tauri host can both reach them via the +//! `application-groups` entitlement, instead of via per-path absolute-path +//! exceptions that cannot be expanded for arbitrary users. +//! +//! On non-macOS platforms (Linux, Windows), IPC files live under the user's +//! XDG config dir at `~/.config/git-same/finder/`. + +pub mod status_file; + +#[cfg(unix)] +pub mod unix_socket; + +pub use status_file::StatusFileWriter; + +#[cfg(unix)] +pub use unix_socket::{UnixSocketClient, UnixSocketListener}; + +use crate::errors::AppError; +use std::path::PathBuf; + +/// App group identifier shared by the monitor, Tauri host, and Badges extension on macOS. +/// +/// Apple requires the team-id prefix; `57KL6Y7V32` is the zaai-com Apple Developer team. +/// The Tauri host's `entitlements.plist` and the Badges extension's +/// `GitSameBadges.entitlements` must declare the same value under +/// `com.apple.security.application-groups`. +pub const APP_GROUP_ID: &str = "group.57KL6Y7V32.com.zaai.git-same"; + +/// IPC configuration paths. +#[derive(Debug, Clone)] +pub struct IpcConfig { + /// Directory containing IPC files (status.json, finder.sock). + pub dir: PathBuf, +} + +impl IpcConfig { + /// Returns the platform-default IPC config. + /// + /// On macOS, this is `~/Library/Group Containers//`. + /// On other platforms (and on macOS when `$HOME` is unavailable), this is + /// the legacy `~/.config/git-same/finder/`. + pub fn default_path() -> Result { + #[cfg(target_os = "macos")] + { + if let Some(group_dir) = macos_group_container_dir() { + return Ok(Self { dir: group_dir }); + } + // Fall through to legacy if HOME is unset (test environments). + } + Self::legacy_default_path() + } + + /// Returns the legacy `~/.config/git-same/finder/` path. + /// + /// Used as the macOS fallback and as the source side of legacy-symlink + /// migration on macOS (see `status_file::ensure_legacy_symlinks`). + pub fn legacy_default_path() -> Result { + let config_dir = crate::config::Config::default_path()?; + let base_dir = config_dir + .parent() + .ok_or_else(|| AppError::config("Could not determine config directory"))?; + Ok(Self { + dir: base_dir.join("finder"), + }) + } + + /// Path to the status JSON file. + pub fn status_file_path(&self) -> PathBuf { + self.dir.join("status.json") + } + + /// Path to the Unix socket (macOS/Linux). + #[cfg(unix)] + pub fn socket_path(&self) -> PathBuf { + self.dir.join("finder.sock") + } + + /// Path to the preferences JSON file. + pub fn preferences_path(&self) -> PathBuf { + self.dir.join("preferences.json") + } + + /// Ensures the IPC directory exists. + pub fn ensure_dir(&self) -> Result<(), AppError> { + std::fs::create_dir_all(&self.dir).map_err(|e| { + AppError::path(format!( + "Failed to create IPC directory '{}': {}", + self.dir.display(), + e + )) + }) + } +} + +/// Resolves the macOS app-group container directory based on `$HOME`. +/// +/// Returns `None` when `$HOME` is unset (typically only inside tests). The +/// directory itself is created on demand by `IpcConfig::ensure_dir`; it does +/// NOT need to pre-exist for this function to return `Some`. +#[cfg(target_os = "macos")] +pub(crate) fn macos_group_container_dir() -> Option { + let home = std::env::var_os("HOME")?; + Some( + PathBuf::from(home) + .join("Library") + .join("Group Containers") + .join(APP_GROUP_ID), + ) +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/crates/git-same-core/src/ipc/mod_tests.rs b/crates/git-same-core/src/ipc/mod_tests.rs new file mode 100644 index 0000000..177a471 --- /dev/null +++ b/crates/git-same-core/src/ipc/mod_tests.rs @@ -0,0 +1,97 @@ +use super::*; + +#[test] +fn test_ipc_config_paths() { + let config = IpcConfig { + dir: PathBuf::from("/home/user/.config/git-same/finder"), + }; + assert_eq!( + config.status_file_path(), + PathBuf::from("/home/user/.config/git-same/finder/status.json") + ); + assert_eq!( + config.preferences_path(), + PathBuf::from("/home/user/.config/git-same/finder/preferences.json") + ); +} + +#[cfg(unix)] +#[test] +fn test_ipc_config_socket_path() { + let config = IpcConfig { + dir: PathBuf::from("/home/user/.config/git-same/finder"), + }; + assert_eq!( + config.socket_path(), + PathBuf::from("/home/user/.config/git-same/finder/finder.sock") + ); +} + +#[test] +fn test_ensure_dir_creates_directory() { + let temp = tempfile::tempdir().unwrap(); + let config = IpcConfig { + dir: temp.path().join("finder"), + }; + assert!(!config.dir.exists()); + config.ensure_dir().unwrap(); + assert!(config.dir.exists()); +} + +#[test] +fn test_app_group_id_has_team_prefix() { + // Apple requires the team-id prefix on app-group identifiers; this guard + // catches accidental edits to the constant. + assert!(APP_GROUP_ID.starts_with("group.57KL6Y7V32.")); + assert!(APP_GROUP_ID.ends_with(".com.zaai.git-same")); +} + +#[cfg(target_os = "macos")] +#[test] +fn test_macos_group_container_dir_includes_app_group_segment() { + // We don't mutate HOME (env mutation races with parallel tests); instead + // we just assert that, when HOME is set in the inherited environment, the + // function returns a path under Library/Group Containers/. + if let Some(dir) = macos_group_container_dir() { + let dir_str = dir.to_string_lossy(); + assert!( + dir_str.contains("/Library/Group Containers/"), + "expected Library/Group Containers/ in path, got {}", + dir_str + ); + assert!( + dir_str.ends_with(APP_GROUP_ID), + "expected to end with {}, got {}", + APP_GROUP_ID, + dir_str + ); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn test_default_path_uses_group_container_on_macos() { + if std::env::var_os("HOME").is_none() { + return; + } + let cfg = IpcConfig::default_path().expect("default_path"); + assert!( + cfg.dir + .ends_with("Library/Group Containers/group.57KL6Y7V32.com.zaai.git-same"), + "expected group-container suffix, got {}", + cfg.dir.display() + ); +} + +#[test] +fn test_legacy_default_path_ends_in_finder() { + // legacy_default_path leans on Config::default_path which respects XDG + // env vars; we just sanity-check the suffix. + if let Ok(cfg) = IpcConfig::legacy_default_path() { + assert!( + cfg.dir.ends_with("git-same/finder"), + "expected 'git-same/finder' suffix, got {}", + cfg.dir.display() + ); + } +} diff --git a/crates/git-same-core/src/ipc/status_file.rs b/crates/git-same-core/src/ipc/status_file.rs new file mode 100644 index 0000000..81ad9b7 --- /dev/null +++ b/crates/git-same-core/src/ipc/status_file.rs @@ -0,0 +1,207 @@ +//! Atomic JSON status file writer and reader. +//! +//! The monitor writes the status file atomically by writing to a temporary +//! file first, then renaming it. This ensures the FinderSync extension +//! never reads a partial/corrupt file. + +use crate::errors::AppError; +use crate::types::finder_status::FinderStatus; +use std::path::{Path, PathBuf}; + +/// Writes and reads the Finder status JSON file atomically. +#[derive(Debug, Clone)] +pub struct StatusFileWriter { + path: PathBuf, +} + +impl StatusFileWriter { + /// Creates a writer for the given status file path. + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + /// The path this writer writes to. + pub fn path(&self) -> &Path { + &self.path + } + + /// Writes the status atomically (write to temp, then rename). + pub fn write(&self, status: &FinderStatus) -> Result<(), AppError> { + let json = serde_json::to_string_pretty(status) + .map_err(|e| AppError::config(format!("Failed to serialize finder status: {}", e)))?; + + let temp_path = self.path.with_extension("json.tmp"); + + // Ensure parent directory exists + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + AppError::path(format!( + "Failed to create directory '{}': {}", + parent.display(), + e + )) + })?; + } + + // Write to temp file + std::fs::write(&temp_path, &json).map_err(|e| { + AppError::path(format!( + "Failed to write temp status file '{}': {}", + temp_path.display(), + e + )) + })?; + + // Atomic rename + std::fs::rename(&temp_path, &self.path).map_err(|e| { + AppError::path(format!( + "Failed to rename '{}' → '{}': {}", + temp_path.display(), + self.path.display(), + e + )) + })?; + + Ok(()) + } + + /// Reads and parses the status file. + pub fn read(&self) -> Result { + let content = std::fs::read_to_string(&self.path).map_err(|e| { + AppError::path(format!( + "Failed to read status file '{}': {}", + self.path.display(), + e + )) + })?; + + serde_json::from_str(&content) + .map_err(|e| AppError::config(format!("Failed to parse status file: {}", e))) + } + + /// Checks if the status file exists. + pub fn exists(&self) -> bool { + self.path.exists() + } +} + +/// On macOS, ensures `~/.config/git-same/finder/{status.json, finder.sock}` are +/// symlinks pointing into the app-group container directory. +/// +/// Idempotent. If a legacy regular file already exists at the destination, it +/// is renamed aside as `.user-saved-` and a `warn` log +/// line is emitted, then the symlink is created. If the legacy directory +/// itself does not exist (fresh install), this is a no-op. +/// +/// Pre-existing 3.x users had the monitor writing to `~/.config/git-same/finder/` +/// and the FinderSync extension reading from it via an absolute-path entitlement +/// exception. After Phase B.5, the monitor writes to the group container +/// directly; this helper makes any tool that hardcoded the legacy path +/// continue to work via symlink redirection. +#[cfg(target_os = "macos")] +pub fn ensure_legacy_symlinks(group_dir: &Path) -> Result<(), AppError> { + let legacy_dir = match super::IpcConfig::legacy_default_path() { + Ok(cfg) => cfg.dir, + Err(_) => return Ok(()), + }; + + if !legacy_dir.exists() { + // Fresh install (no XDG config dir at all yet); nothing to migrate. + return Ok(()); + } + + for filename in &["status.json", "finder.sock"] { + let legacy_path = legacy_dir.join(filename); + let target_path = group_dir.join(filename); + ensure_one_symlink(&legacy_path, &target_path)?; + } + + Ok(()) +} + +/// Non-macOS no-op so the monitor can call this unconditionally without `cfg` +/// gates at the call site. +#[cfg(not(target_os = "macos"))] +pub fn ensure_legacy_symlinks(_group_dir: &Path) -> Result<(), AppError> { + Ok(()) +} + +#[cfg(target_os = "macos")] +fn ensure_one_symlink(legacy_path: &Path, target_path: &Path) -> Result<(), AppError> { + use std::os::unix::fs::symlink; + + match std::fs::symlink_metadata(legacy_path) { + Ok(meta) if meta.file_type().is_symlink() => { + if std::fs::read_link(legacy_path).ok().as_deref() == Some(target_path) { + return Ok(()); + } + std::fs::remove_file(legacy_path).map_err(|e| { + AppError::path(format!( + "Failed to remove stale symlink '{}': {}", + legacy_path.display(), + e + )) + })?; + } + Ok(_) => { + let aside = aside_path(legacy_path); + std::fs::rename(legacy_path, &aside).map_err(|e| { + AppError::path(format!( + "Failed to rename legacy file '{}' to '{}': {}", + legacy_path.display(), + aside.display(), + e + )) + })?; + tracing::warn!( + legacy = %legacy_path.display(), + aside = %aside.display(), + target = %target_path.display(), + "Renamed legacy regular file aside; replacing with symlink to group container" + ); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // No legacy file; just create the symlink. + } + Err(e) => { + return Err(AppError::path(format!( + "Failed to inspect '{}': {}", + legacy_path.display(), + e + ))); + } + } + + if let Some(parent) = legacy_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + AppError::path(format!( + "Failed to create legacy parent dir '{}': {}", + parent.display(), + e + )) + })?; + } + + symlink(target_path, legacy_path).map_err(|e| { + AppError::path(format!( + "Failed to symlink '{}' -> '{}': {}", + legacy_path.display(), + target_path.display(), + e + )) + })?; + Ok(()) +} + +#[cfg(target_os = "macos")] +fn aside_path(path: &Path) -> PathBuf { + use std::ffi::OsString; + let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string(); + let mut name = OsString::from(path.as_os_str()); + name.push(format!(".user-saved-{}", stamp)); + PathBuf::from(name) +} + +#[cfg(test)] +#[path = "status_file_tests.rs"] +mod tests; diff --git a/crates/git-same-core/src/ipc/status_file_tests.rs b/crates/git-same-core/src/ipc/status_file_tests.rs new file mode 100644 index 0000000..98831d5 --- /dev/null +++ b/crates/git-same-core/src/ipc/status_file_tests.rs @@ -0,0 +1,208 @@ +use super::*; +use crate::types::finder_status::{ + Badge, FinderBranchInfo, FinderRepoStatus, FinderStatus, FinderWorkspaceInfo, +}; +use std::path::PathBuf; + +fn sample_status() -> FinderStatus { + let mut status = FinderStatus::new(12345, "2026-04-04T10:30:00Z".to_string()); + status.workspaces.push(FinderWorkspaceInfo { + name: "github".to_string(), + root: PathBuf::from("/Users/test/repos"), + orgs: vec!["zaai-com".to_string()], + }); + status.repos.push(FinderRepoStatus { + path: PathBuf::from("/Users/test/repos/zaai-com/git-same"), + workspace: Some("github".to_string()), + org: Some("zaai-com".to_string()), + badge: Badge::Green, + current_branch: "main".to_string(), + default_branch: Some("main".to_string()), + commit_count: 847, + staged_count: 0, + unstaged_count: 0, + untracked_count: 0, + ahead: 0, + behind: 0, + stash_count: 0, + has_important_ignored_files: false, + important_ignored_files: Vec::new(), + branches: vec![FinderBranchInfo { + name: "main".to_string(), + upstream: Some("origin/main".to_string()), + ahead: 0, + behind: 0, + synced: true, + }], + all_branches_synced: true, + remotes: vec![], + worktrees: Vec::new(), + all_worktrees_synced: true, + read_error: None, + }); + status +} + +#[test] +fn test_write_and_read_roundtrip() { + let temp = tempfile::tempdir().unwrap(); + let writer = StatusFileWriter::new(temp.path().join("status.json")); + + let status = sample_status(); + writer.write(&status).unwrap(); + + assert!(writer.exists()); + + let read_back = writer.read().unwrap(); + assert_eq!(read_back, status); +} + +#[test] +fn test_write_creates_parent_dirs() { + let temp = tempfile::tempdir().unwrap(); + let writer = StatusFileWriter::new(temp.path().join("sub/dir/status.json")); + + let status = FinderStatus::new(1, "now".to_string()); + writer.write(&status).unwrap(); + + assert!(writer.exists()); +} + +#[test] +fn test_write_overwrites_existing() { + let temp = tempfile::tempdir().unwrap(); + let writer = StatusFileWriter::new(temp.path().join("status.json")); + + let status1 = FinderStatus::new(1, "first".to_string()); + writer.write(&status1).unwrap(); + + let status2 = FinderStatus::new(2, "second".to_string()); + writer.write(&status2).unwrap(); + + let read_back = writer.read().unwrap(); + assert_eq!(read_back.daemon_pid, 2); + assert_eq!(read_back.timestamp, "second"); +} + +#[test] +fn test_read_nonexistent_file() { + let writer = StatusFileWriter::new(PathBuf::from("/nonexistent/status.json")); + assert!(!writer.exists()); + assert!(writer.read().is_err()); +} + +#[test] +fn test_no_temp_file_remains_after_write() { + let temp = tempfile::tempdir().unwrap(); + let writer = StatusFileWriter::new(temp.path().join("status.json")); + + let status = FinderStatus::new(1, "now".to_string()); + writer.write(&status).unwrap(); + + // The .tmp file should not exist after atomic rename + let temp_path = temp.path().join("status.json.tmp"); + assert!(!temp_path.exists()); +} + +#[cfg(target_os = "macos")] +mod symlink_helper { + use super::*; + use std::fs; + use std::os::unix::fs::symlink; + + fn dirs() -> (tempfile::TempDir, PathBuf, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let legacy = root.path().join("legacy"); + let group = root.path().join("group"); + fs::create_dir_all(&legacy).unwrap(); + fs::create_dir_all(&group).unwrap(); + (root, legacy, group) + } + + #[test] + fn creates_fresh_symlink_when_no_legacy_file_exists() { + let (_root, legacy, group) = dirs(); + let legacy_file = legacy.join("status.json"); + let target_file = group.join("status.json"); + + ensure_one_symlink(&legacy_file, &target_file).unwrap(); + + let meta = fs::symlink_metadata(&legacy_file).unwrap(); + assert!(meta.file_type().is_symlink()); + assert_eq!(fs::read_link(&legacy_file).unwrap(), target_file); + } + + #[test] + fn is_idempotent_when_correct_symlink_already_exists() { + let (_root, legacy, group) = dirs(); + let legacy_file = legacy.join("status.json"); + let target_file = group.join("status.json"); + + symlink(&target_file, &legacy_file).unwrap(); + ensure_one_symlink(&legacy_file, &target_file).unwrap(); + + // Still a symlink, still pointing where we expect. + let meta = fs::symlink_metadata(&legacy_file).unwrap(); + assert!(meta.file_type().is_symlink()); + assert_eq!(fs::read_link(&legacy_file).unwrap(), target_file); + } + + #[test] + fn replaces_stale_symlink_pointing_elsewhere() { + let (_root, legacy, group) = dirs(); + let legacy_file = legacy.join("status.json"); + let target_file = group.join("status.json"); + let other = legacy.join("somewhere-else"); + + symlink(&other, &legacy_file).unwrap(); + ensure_one_symlink(&legacy_file, &target_file).unwrap(); + + assert_eq!(fs::read_link(&legacy_file).unwrap(), target_file); + } + + #[test] + fn renames_aside_when_legacy_is_a_regular_file() { + let (_root, legacy, group) = dirs(); + let legacy_file = legacy.join("status.json"); + let target_file = group.join("status.json"); + + fs::write(&legacy_file, b"old user data").unwrap(); + ensure_one_symlink(&legacy_file, &target_file).unwrap(); + + // Legacy path is now a symlink. + let meta = fs::symlink_metadata(&legacy_file).unwrap(); + assert!(meta.file_type().is_symlink()); + assert_eq!(fs::read_link(&legacy_file).unwrap(), target_file); + + // The original file's contents survive at status.json.user-saved-. + let aside_count = fs::read_dir(&legacy) + .unwrap() + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("status.json.user-saved-") + }) + .count(); + assert_eq!(aside_count, 1, "expected one aside file"); + } + + #[test] + fn ensure_legacy_symlinks_is_noop_when_legacy_dir_missing() { + // Use a non-existent legacy dir override path: we can't easily inject + // a custom legacy dir into the public helper, so we exercise the + // private one with a known-missing legacy path. + let (_root, _legacy, group) = dirs(); + let missing_legacy_file = + PathBuf::from("/nonexistent/path/that/should/not/exist/status.json"); + // ensure_one_symlink should still happily create a symlink if the + // parent can be created; we sanity-check by NOT creating the parent + // and asserting we get an error rather than a crash. + // (Linux/macOS will fail at `create_dir_all` for a path we cannot + // write to.) + let _ = ensure_one_symlink(&missing_legacy_file, &group.join("status.json")); + // No assertion about success/failure here; the point is just that + // the helper does not panic on unexpected inputs. + } +} diff --git a/crates/git-same-core/src/ipc/unix_socket.rs b/crates/git-same-core/src/ipc/unix_socket.rs new file mode 100644 index 0000000..aacf3ec --- /dev/null +++ b/crates/git-same-core/src/ipc/unix_socket.rs @@ -0,0 +1,219 @@ +//! Unix domain socket IPC for macOS and Linux. +//! +//! The monitor listens on a Unix socket for commands from the FinderSync +//! extension (or CLI tools). Commands are text-based, one per line. +//! +//! ## Protocol +//! +//! ```text +//! REFRESH /path/to/folder\n → re-scan folder + subfolders, respond "OK\n" +//! REFRESH_ALL\n → re-scan everything, respond "OK\n" +//! STATUS\n → respond with full status JSON +//! PING\n → respond "PONG\n" (health check) +//! ``` + +use crate::errors::AppError; +use std::path::{Path, PathBuf}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener as TokioUnixListener, UnixStream}; +use tracing::{debug, warn}; + +/// Commands the monitor can receive over the socket. +/// +/// The enum name `DaemonCommand` is preserved (not renamed to `MonitorCommand`) +/// because it is purely an internal Rust type; renaming it would be wire-format +/// churn with zero user benefit. The text protocol words (`PING`, `REFRESH`, +/// `STATUS`, `REFRESH_ALL`) are likewise unchanged. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DaemonCommand { + /// Re-scan a specific path and its subfolders. + Refresh(PathBuf), + /// Re-scan all monitored paths. + RefreshAll, + /// Return the current status JSON. + Status, + /// Health check. + Ping, + /// Unknown command. + Unknown(String), +} + +impl DaemonCommand { + /// Parse a command from a text line. + pub fn parse(line: &str) -> Self { + let trimmed = line.trim(); + if let Some(path) = trimmed.strip_prefix("REFRESH ") { + DaemonCommand::Refresh(PathBuf::from(path)) + } else if trimmed == "REFRESH_ALL" { + DaemonCommand::RefreshAll + } else if trimmed == "STATUS" { + DaemonCommand::Status + } else if trimmed == "PING" { + DaemonCommand::Ping + } else { + DaemonCommand::Unknown(trimmed.to_string()) + } + } +} + +/// Unix socket listener for the monitor. +pub struct UnixSocketListener { + path: PathBuf, +} + +impl UnixSocketListener { + /// Creates a new listener for the given socket path. + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + /// The socket path. + pub fn path(&self) -> &Path { + &self.path + } + + /// Bind and start listening. Removes stale socket file if present. + pub async fn bind(&self) -> Result { + // Remove stale socket file from a previous run + if self.path.exists() { + std::fs::remove_file(&self.path).map_err(|e| { + AppError::path(format!( + "Failed to remove stale socket '{}': {}", + self.path.display(), + e + )) + })?; + } + + // Ensure parent directory exists + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + AppError::path(format!( + "Failed to create socket directory '{}': {}", + parent.display(), + e + )) + })?; + } + + TokioUnixListener::bind(&self.path).map_err(|e| { + AppError::path(format!( + "Failed to bind Unix socket '{}': {}", + self.path.display(), + e + )) + }) + } + + /// Cleans up the socket file on shutdown. + pub fn cleanup(&self) { + if self.path.exists() { + if let Err(e) = std::fs::remove_file(&self.path) { + warn!( + path = %self.path.display(), + error = %e, + "Failed to remove socket file during cleanup" + ); + } + } + } +} + +/// Read a single command from a connected Unix stream. +pub async fn read_command(stream: &mut BufReader) -> Result { + let mut line = String::new(); + let bytes_read = stream + .read_line(&mut line) + .await + .map_err(|e| AppError::config(format!("Failed to read from socket: {}", e)))?; + + if bytes_read == 0 { + return Err(AppError::config("Socket connection closed")); + } + + Ok(DaemonCommand::parse(&line)) +} + +/// Write a response to a connected Unix stream. +pub async fn write_response(stream: &mut UnixStream, response: &str) -> Result<(), AppError> { + stream + .write_all(response.as_bytes()) + .await + .map_err(|e| AppError::config(format!("Failed to write to socket: {}", e)))?; + stream + .flush() + .await + .map_err(|e| AppError::config(format!("Failed to flush socket: {}", e)))?; + Ok(()) +} + +/// Client for connecting to the monitor's Unix socket. +pub struct UnixSocketClient { + path: PathBuf, +} + +impl UnixSocketClient { + /// Creates a client targeting the given socket path. + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + /// Send a command and receive the response. + pub async fn send(&self, command: &str) -> Result { + let mut stream = UnixStream::connect(&self.path).await.map_err(|e| { + AppError::path(format!( + "Failed to connect to monitor socket '{}': {}", + self.path.display(), + e + )) + })?; + + // Send command + let msg = format!("{}\n", command); + stream + .write_all(msg.as_bytes()) + .await + .map_err(|e| AppError::config(format!("Failed to send command: {}", e)))?; + stream + .flush() + .await + .map_err(|e| AppError::config(format!("Failed to flush: {}", e)))?; + + // Read response + let mut reader = BufReader::new(stream); + let mut response = String::new(); + reader + .read_line(&mut response) + .await + .map_err(|e| AppError::config(format!("Failed to read response: {}", e)))?; + + debug!( + command, + response = response.trim(), + "Socket command completed" + ); + Ok(response) + } + + /// Ping the monitor. Returns true if it responds. + pub async fn ping(&self) -> bool { + match self.send("PING").await { + Ok(response) => response.trim() == "PONG", + Err(_) => false, + } + } + + /// Request a refresh of a specific path. + pub async fn refresh(&self, path: &Path) -> Result { + self.send(&format!("REFRESH {}", path.display())).await + } + + /// Request a full refresh of all monitored paths. + pub async fn refresh_all(&self) -> Result { + self.send("REFRESH_ALL").await + } +} + +#[cfg(test)] +#[path = "unix_socket_tests.rs"] +mod tests; diff --git a/crates/git-same-core/src/ipc/unix_socket_tests.rs b/crates/git-same-core/src/ipc/unix_socket_tests.rs new file mode 100644 index 0000000..11c2920 --- /dev/null +++ b/crates/git-same-core/src/ipc/unix_socket_tests.rs @@ -0,0 +1,118 @@ +use super::*; + +#[test] +fn test_parse_command_ping() { + assert_eq!(DaemonCommand::parse("PING"), DaemonCommand::Ping); + assert_eq!(DaemonCommand::parse("PING\n"), DaemonCommand::Ping); +} + +#[test] +fn test_parse_command_refresh() { + assert_eq!( + DaemonCommand::parse("REFRESH /path/to/repo"), + DaemonCommand::Refresh(PathBuf::from("/path/to/repo")) + ); +} + +#[test] +fn test_parse_command_refresh_all() { + assert_eq!( + DaemonCommand::parse("REFRESH_ALL"), + DaemonCommand::RefreshAll + ); +} + +#[test] +fn test_parse_command_status() { + assert_eq!(DaemonCommand::parse("STATUS"), DaemonCommand::Status); +} + +#[test] +fn test_parse_command_unknown() { + assert_eq!( + DaemonCommand::parse("FOOBAR"), + DaemonCommand::Unknown("FOOBAR".to_string()) + ); +} + +#[test] +fn test_parse_command_refresh_with_spaces_in_path() { + assert_eq!( + DaemonCommand::parse("REFRESH /path/to/my repo"), + DaemonCommand::Refresh(PathBuf::from("/path/to/my repo")) + ); +} + +#[test] +fn test_parse_command_refresh_preserves_leading_space_in_path() { + // The path argument is no longer inner-trimmed, so whitespace that is + // part of the path (after the single "REFRESH " delimiter) is preserved. + assert_eq!( + DaemonCommand::parse("REFRESH /leading-space"), + DaemonCommand::Refresh(PathBuf::from(" /leading-space")) + ); +} + +#[tokio::test] +async fn test_socket_listener_bind_and_cleanup() { + let temp = tempfile::tempdir().unwrap(); + let sock_path = temp.path().join("test.sock"); + let listener = UnixSocketListener::new(sock_path.clone()); + + // Bind should succeed + let _tokio_listener = listener.bind().await.unwrap(); + assert!(sock_path.exists()); + + // Cleanup should remove the socket + listener.cleanup(); + assert!(!sock_path.exists()); +} + +#[tokio::test] +async fn test_socket_listener_removes_stale_socket() { + let temp = tempfile::tempdir().unwrap(); + let sock_path = temp.path().join("test.sock"); + + // Create a stale socket file + std::fs::write(&sock_path, "stale").unwrap(); + assert!(sock_path.exists()); + + let listener = UnixSocketListener::new(sock_path.clone()); + let _tokio_listener = listener.bind().await.unwrap(); + + // Should have removed the stale file and created a real socket + assert!(sock_path.exists()); + listener.cleanup(); +} + +#[tokio::test] +async fn test_socket_client_server_roundtrip() { + let temp = tempfile::tempdir().unwrap(); + let sock_path = temp.path().join("test.sock"); + + let listener = UnixSocketListener::new(sock_path.clone()); + let tokio_listener = listener.bind().await.unwrap(); + + // Spawn a simple server that responds to PING + let server = tokio::spawn(async move { + let (stream, _) = tokio_listener.accept().await.unwrap(); + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + + let cmd = DaemonCommand::parse(&line); + assert_eq!(cmd, DaemonCommand::Ping); + + let stream = reader.into_inner(); + let mut stream = stream; + write_response(&mut stream, "PONG\n").await.unwrap(); + }); + + // Client sends PING + let client = UnixSocketClient::new(sock_path); + let is_alive = client.ping().await; + assert!(is_alive); + + server.await.unwrap(); + listener.cleanup(); +} diff --git a/crates/git-same-core/src/lib.rs b/crates/git-same-core/src/lib.rs new file mode 100644 index 0000000..7db189e --- /dev/null +++ b/crates/git-same-core/src/lib.rs @@ -0,0 +1,78 @@ +//! # git-same-core +//! +//! Engine for git-same: discovery, clone/sync orchestration, IPC, status. +//! See the `git-same` (CLI) crate for the user-facing binary. + +pub mod api; +pub mod auth; +pub mod cache; +pub mod checks; +pub mod config; +/// Public discovery planning APIs. +/// +/// This module is part of the crate's public surface and is re-exported by +/// [`prelude`]. Prefer the higher-level [`workflows`] and [`api`] modules for +/// end-user flows unless callers need to build an action plan directly. +pub mod discovery; +pub mod domain; +pub mod errors; +pub mod git; +#[deprecated( + since = "3.2.0", + note = "use the cache, config, git, and provider modules directly; infra is a legacy facade" +)] +pub mod infra; +pub mod ipc; +pub mod macos; +pub mod monitor; +pub mod operations; +pub mod output; +pub mod progress; +pub mod provider; +pub mod setup; +pub mod types; +pub mod workflows; + +/// Re-export commonly used types for convenience. +pub mod prelude { + pub use crate::auth::{get_auth, get_auth_for_provider, AuthResult}; + pub use crate::cache::{CacheManager, DiscoveryCache, SyncHistoryManager, CACHE_VERSION}; + pub use crate::config::{ + Config, ConfigCloneOptions, FilterOptions, SyncMode as ConfigSyncMode, WorkspaceConfig, + WorkspaceProvider, + }; + pub use crate::discovery::DiscoveryOrchestrator; + pub use crate::domain::RepoPathTemplate; + pub use crate::errors::{AppError, GitError, ProviderError, Result}; + pub use crate::git::{ + CloneOptions, FetchResult, GitOperations, PullResult, RepoStatus, ShellGit, + }; + pub use crate::operations::clone::{ + CloneManager, CloneManagerOptions, CloneProgress, CloneResult, + }; + pub use crate::operations::sync::{ + LocalRepo, SyncManager, SyncManagerOptions, SyncMode, SyncResult, + }; + pub use crate::output::{ + CloneProgressBar, DiscoveryProgressBar, Output, SyncProgressBar, Verbosity, + }; + pub use crate::progress::{ProgressEvent, ProgressReporter}; + pub use crate::provider::{ + create_provider, Credentials, DiscoveryOptions, DiscoveryProgress, NoProgress, Provider, + RateLimitInfo, + }; + pub use crate::setup::{ + apply_requirements_check_results, authenticate_provider, discover_org_entries, + maybe_start_requirements_checks, run_requirements_checks, save_workspace, AuthStatus, + OrgEntry, PathBrowseEntry, PathSuggestion, ProviderChoice, SetupAuthResult, SetupOutcome, + SetupState, SetupStep, + }; + pub use crate::types::{ + ActionPlan, OpResult, OpSummary, Org, OwnedRepo, ProviderKind, Repo, RepoEntry, + SyncHistoryEntry, + }; +} + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; diff --git a/src/lib_tests.rs b/crates/git-same-core/src/lib_tests.rs similarity index 55% rename from src/lib_tests.rs rename to crates/git-same-core/src/lib_tests.rs index 3d96e46..884527f 100644 --- a/src/lib_tests.rs +++ b/crates/git-same-core/src/lib_tests.rs @@ -14,11 +14,26 @@ fn prelude_reexports_core_types() { let repo = Repo::test("rocket", "acme"); let owned = OwnedRepo::new("acme", repo); assert_eq!(owned.full_name(), "acme/rocket"); + + let progress = ProgressEvent::DiscoveryOrgsDiscovered { count: 1 }; + assert!(matches!( + progress, + ProgressEvent::DiscoveryOrgsDiscovered { count: 1 } + )); + + let setup = SetupState::new("~/Git-Same/GitHub"); + assert_eq!(setup.step, SetupStep::Requirements); } #[test] fn top_level_modules_are_accessible() { + let _ = discovery::DiscoveryOrchestrator::new( + config::FilterOptions::default(), + "{org}/{repo}".to_string(), + ); let _ = output::Verbosity::Normal; let _ = operations::sync::SyncMode::Fetch; + let _ = progress::ProgressEvent::DiscoveryPersonalReposStarted; + let _ = setup::SetupStep::Requirements; let _ = types::ProviderKind::GitLab; } diff --git a/crates/git-same-core/src/macos/folder_icon.rs b/crates/git-same-core/src/macos/folder_icon.rs new file mode 100644 index 0000000..37dd722 --- /dev/null +++ b/crates/git-same-core/src/macos/folder_icon.rs @@ -0,0 +1,160 @@ +//! Paint a custom folder icon on a workspace root. +//! +//! On macOS this wraps `NSWorkspace.setIcon(_:forFile:options:)`, which writes +//! an `Icon\r` resource file inside the folder and sets the `kHasCustomIcon` +//! flag in `com.apple.FinderInfo`. Finder then renders the icon in every +//! view — sidebar, column, list, icon, and Get Info preview — exactly like +//! Synology Drive paints its "D" logo onto its synced folders. +//! +//! On non-macOS targets every entry point is a no-op so callers don't need +//! their own cfg-gating. + +use crate::errors::{AppError, Result}; +use std::path::Path; + +/// The ICNS payload bundled into the binary. Painted onto every workspace +/// root when [`set`] is called. Regenerate via +/// `bash toolkit/icons/build-workspace-folder-icns.sh`. +pub static WORKSPACE_FOLDER_ICNS: &[u8] = include_bytes!("../../assets/workspace-folder.icns"); + +/// Returns true when `path` already carries a custom icon. macOS marks this by +/// creating a hidden `Icon\r` (carriage-return) child file; checking for that +/// file is faster and more reliable than parsing the folder's FinderInfo +/// xattr, and matches what Finder itself looks for. +pub fn is_set(path: &Path) -> bool { + #[cfg(target_os = "macos")] + { + path.join("Icon\r").exists() + } + #[cfg(not(target_os = "macos"))] + { + let _ = path; + false + } +} + +/// Paint `icns_bytes` as the custom folder icon for `path`. Idempotent: if +/// the same icon is already set, Finder is a no-op. Returns an error if `path` +/// is not a directory or if the Cocoa call fails. +/// +/// On non-macOS targets this is a no-op that returns `Ok(())`. +pub fn set(path: &Path, icns_bytes: &[u8]) -> Result<()> { + #[cfg(target_os = "macos")] + { + imp::set_icon(path, Some(icns_bytes)) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (path, icns_bytes); + Ok(()) + } +} + +/// Remove the custom folder icon from `path`. Safe to call when no icon is +/// set. On non-macOS targets this is a no-op that returns `Ok(())`. +pub fn clear(path: &Path) -> Result<()> { + #[cfg(target_os = "macos")] + { + imp::set_icon(path, None) + } + #[cfg(not(target_os = "macos"))] + { + let _ = path; + Ok(()) + } +} + +/// Convenience wrapper: log-and-swallow variant for hot paths (workspace +/// creation, monitor loop) where a painting failure should never break the +/// caller's primary task. +pub fn set_or_log(path: &Path, icns_bytes: &[u8]) { + if let Err(e) = set(path, icns_bytes) { + tracing::warn!( + path = %path.display(), + error = %e, + "Failed to paint workspace folder icon; continuing" + ); + } +} + +/// Same idea as [`set_or_log`], for cleanup paths. +pub fn clear_or_log(path: &Path) { + if let Err(e) = clear(path) { + tracing::warn!( + path = %path.display(), + error = %e, + "Failed to clear workspace folder icon; continuing" + ); + } +} + +#[cfg(target_os = "macos")] +mod imp { + use super::{AppError, Path, Result}; + use objc2::AnyThread; + use objc2_app_kit::{NSImage, NSWorkspace, NSWorkspaceIconCreationOptions}; + use objc2_foundation::{NSData, NSString}; + + /// Call NSWorkspace.setIcon. Pass `None` to remove the existing icon. + pub(super) fn set_icon(path: &Path, icns_bytes: Option<&[u8]>) -> Result<()> { + if !path.is_dir() { + return Err(AppError::config(format!( + "set_icon target is not a directory: {}", + path.display() + ))); + } + + let path_str = path.to_str().ok_or_else(|| { + AppError::config(format!( + "set_icon path is not valid UTF-8: {}", + path.display() + )) + })?; + + // Cocoa calls below mutate filesystem state and use autorelease pools; + // we run them inside an explicit autoreleasepool so any temporary + // objects (NSData, NSImage, NSString) drain when the block returns + // even if the caller is a long-running daemon (e.g. the monitor). + objc2::rc::autoreleasepool(|_| -> Result<()> { + let ns_path = NSString::from_str(path_str); + + let image = match icns_bytes { + Some(bytes) => { + let data = NSData::with_bytes(bytes); + // NSImage.initWithData: returns nil if the data isn't a + // recognized image format. We treat nil as a soft failure. + let img = NSImage::initWithData(NSImage::alloc(), &data); + match img { + Some(i) => Some(i), + None => { + return Err(AppError::config( + "NSImage could not decode workspace-folder ICNS bytes", + )); + } + } + } + None => None, + }; + + let workspace = NSWorkspace::sharedWorkspace(); + // setIcon:forFile:options: returns BOOL. We honor `false` as a + // soft failure so callers see a clear error. + let ok = workspace.setIcon_forFile_options( + image.as_deref(), + &ns_path, + NSWorkspaceIconCreationOptions(0), + ); + if !ok { + return Err(AppError::config(format!( + "NSWorkspace.setIcon returned false for {}", + path.display() + ))); + } + Ok(()) + }) + } +} + +#[cfg(all(test, target_os = "macos"))] +#[path = "folder_icon_tests.rs"] +mod tests; diff --git a/crates/git-same-core/src/macos/folder_icon_tests.rs b/crates/git-same-core/src/macos/folder_icon_tests.rs new file mode 100644 index 0000000..388a35d --- /dev/null +++ b/crates/git-same-core/src/macos/folder_icon_tests.rs @@ -0,0 +1,49 @@ +use super::*; +use tempfile::tempdir; + +#[test] +fn set_then_clear_round_trip() { + let dir = tempdir().expect("tempdir"); + let root = dir.path(); + + // Fresh directory has no custom icon and no Icon\r file. + assert!(!is_set(root), "fresh dir already has custom icon"); + + // Paint the workspace folder icon. + set(root, WORKSPACE_FOLDER_ICNS).expect("set should succeed"); + assert!(is_set(root), "expected Icon\\r after set"); + let icon_file = root.join("Icon\r"); + assert!(icon_file.exists(), "Icon\\r file missing on disk"); + + // Idempotent: setting again should still succeed. + set(root, WORKSPACE_FOLDER_ICNS).expect("idempotent set should succeed"); + assert!(is_set(root), "Icon\\r should remain after second set"); + + // Clear removes the icon. + clear(root).expect("clear should succeed"); + assert!(!is_set(root), "is_set still true after clear"); + assert!( + !icon_file.exists(), + "Icon\\r still on disk after clear: {}", + icon_file.display() + ); +} + +#[test] +fn set_on_nonexistent_path_errors() { + let dir = tempdir().expect("tempdir"); + let missing = dir.path().join("does-not-exist"); + let err = set(&missing, WORKSPACE_FOLDER_ICNS).expect_err("expected error"); + let msg = err.to_string(); + assert!( + msg.contains("not a directory"), + "unexpected error message: {msg}" + ); +} + +#[test] +fn clear_on_dir_without_icon_is_noop() { + let dir = tempdir().expect("tempdir"); + clear(dir.path()).expect("clear should succeed even without prior icon"); + assert!(!is_set(dir.path())); +} diff --git a/crates/git-same-core/src/macos/mod.rs b/crates/git-same-core/src/macos/mod.rs new file mode 100644 index 0000000..f222374 --- /dev/null +++ b/crates/git-same-core/src/macos/mod.rs @@ -0,0 +1,8 @@ +//! macOS-only host integration helpers. +//! +//! These wrap Cocoa / xattr operations that the FinderSync extension cannot +//! perform from its sandbox — currently only custom workspace folder icons +//! (painted via `NSWorkspace.setIcon`). On non-macOS targets the submodules +//! expose no-op stubs so callers can stay platform-agnostic. + +pub mod folder_icon; diff --git a/crates/git-same-core/src/monitor/incremental.rs b/crates/git-same-core/src/monitor/incremental.rs new file mode 100644 index 0000000..5b94d47 --- /dev/null +++ b/crates/git-same-core/src/monitor/incremental.rs @@ -0,0 +1,99 @@ +//! Per-repo rescans that mutate an in-memory `FinderStatus` in place. +//! +//! Used by both the FSEvents-driven scan loop and the socket `REFRESH ` +//! handler so they share one consistent merge implementation and never need +//! a full `scan_all`. + +use crate::api::RepoScanService; +use crate::types::FinderStatus; +use std::path::{Path, PathBuf}; + +/// Rescan a single repo and merge the result into `status` in place. +/// +/// Returns `true` if `status.repos` actually changed. +/// +/// If the path no longer looks like a git repo, the corresponding entry is +/// removed. +pub fn rescan_and_merge( + service: &RepoScanService<'_>, + status: &mut FinderStatus, + repo_path: &Path, +) -> bool { + let canonical = canonicalize_or_via_parent(repo_path); + + if !is_git_repo(&canonical) { + let before = status.repos.len(); + status.repos.retain(|r| r.path != canonical); + let removed = status.repos.len() != before; + if removed { + status.timestamp = chrono::Utc::now().to_rfc3339(); + } + return removed; + } + + let (workspace, org) = labels_for(status, &canonical); + let new_entry = service.scan_repo(&canonical, workspace.as_deref(), org.as_deref()); + + if let Some(existing) = status.repos.iter_mut().find(|r| r.path == canonical) { + if *existing == new_entry { + return false; + } + *existing = new_entry; + } else { + status.repos.push(new_entry); + } + status.timestamp = chrono::Utc::now().to_rfc3339(); + true +} + +fn is_git_repo(path: &Path) -> bool { + let dot_git = path.join(".git"); + dot_git.is_dir() || dot_git.is_file() || path.join("HEAD").is_file() +} + +/// Recover the workspace + org labels for a repo path. +/// +/// Prefers the existing `FinderStatus` entry (so a repo that was first +/// emitted via `scan_all` keeps the labels chosen there). Falls back to +/// matching against `status.workspaces` for newly-discovered paths. +fn labels_for(status: &FinderStatus, repo_path: &Path) -> (Option, Option) { + if let Some(existing) = status.repos.iter().find(|r| r.path == repo_path) { + return (existing.workspace.clone(), existing.org.clone()); + } + for ws in &status.workspaces { + let ws_root = canonical_or_self(&ws.root); + if let Ok(rel) = repo_path.strip_prefix(&ws_root) { + let org = rel + .components() + .next() + .and_then(|c| c.as_os_str().to_str()) + .map(str::to_string); + return (Some(ws.name.clone()), org); + } + } + (None, None) +} + +fn canonical_or_self(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +/// Canonicalize a path, falling back to canonicalizing the parent and +/// rejoining the filename when the path itself no longer exists. Without +/// this fallback, entries stored under the canonical form become orphaned +/// when the repo is deleted on disk and `canonicalize` starts failing. +fn canonicalize_or_via_parent(path: &Path) -> PathBuf { + if let Ok(canonical) = std::fs::canonicalize(path) { + return canonical; + } + if let (Some(parent), Some(filename)) = (path.parent(), path.file_name()) { + if let Ok(canonical_parent) = std::fs::canonicalize(parent) { + return canonical_parent.join(filename); + } + } + path.to_path_buf() +} + +#[cfg(test)] +#[path = "incremental_tests.rs"] +mod tests; diff --git a/crates/git-same-core/src/monitor/incremental_tests.rs b/crates/git-same-core/src/monitor/incremental_tests.rs new file mode 100644 index 0000000..8ae74de --- /dev/null +++ b/crates/git-same-core/src/monitor/incremental_tests.rs @@ -0,0 +1,85 @@ +use super::*; +use crate::api::RepoScanService; +use crate::config::Config; +use crate::git::traits::mock::MockGit; +use crate::types::finder_status::{Badge, FinderStatus, FinderWorkspaceInfo}; + +fn make_status_with_workspace(name: &str, root: &Path) -> FinderStatus { + let mut status = FinderStatus::new(0, "0".to_string()); + status.workspaces.push(FinderWorkspaceInfo { + name: name.to_string(), + root: root.to_path_buf(), + orgs: Vec::new(), + }); + status +} + +#[test] +fn rescan_inserts_new_repo_entry_with_derived_labels() { + let temp = tempfile::tempdir().unwrap(); + let workspace_root = temp.path().to_path_buf(); + let repo_path = workspace_root.join("acme/widgets"); + std::fs::create_dir_all(repo_path.join(".git")).unwrap(); + + let mock = MockGit::new(); + let mut cfg = Config::default(); + cfg.finder.show_ambient = false; + let service = RepoScanService::new(&mock, &cfg); + + let mut status = make_status_with_workspace("workspace", &workspace_root); + + let changed = rescan_and_merge(&service, &mut status, &repo_path); + + assert!(changed); + let canonical = std::fs::canonicalize(&repo_path).unwrap(); + let entry = status + .repos + .iter() + .find(|r| r.path == canonical) + .expect("repo should be present"); + assert_eq!(entry.workspace.as_deref(), Some("workspace")); + assert_eq!(entry.org.as_deref(), Some("acme")); + assert_eq!(entry.badge, Badge::Green); +} + +#[test] +fn rescan_returns_false_when_nothing_changed() { + let temp = tempfile::tempdir().unwrap(); + let workspace_root = temp.path().to_path_buf(); + let repo_path = workspace_root.join("acme/widgets"); + std::fs::create_dir_all(repo_path.join(".git")).unwrap(); + + let mock = MockGit::new(); + let mut cfg = Config::default(); + cfg.finder.show_ambient = false; + let service = RepoScanService::new(&mock, &cfg); + + let mut status = make_status_with_workspace("workspace", &workspace_root); + + assert!(rescan_and_merge(&service, &mut status, &repo_path)); + let timestamp_after_first = status.timestamp.clone(); + assert!(!rescan_and_merge(&service, &mut status, &repo_path)); + assert_eq!(status.timestamp, timestamp_after_first); +} + +#[test] +fn rescan_removes_entry_when_repo_is_gone() { + let temp = tempfile::tempdir().unwrap(); + let workspace_root = temp.path().to_path_buf(); + let repo_path = workspace_root.join("acme/widgets"); + std::fs::create_dir_all(repo_path.join(".git")).unwrap(); + + let mock = MockGit::new(); + let mut cfg = Config::default(); + cfg.finder.show_ambient = false; + let service = RepoScanService::new(&mock, &cfg); + + let mut status = make_status_with_workspace("workspace", &workspace_root); + rescan_and_merge(&service, &mut status, &repo_path); + + std::fs::remove_dir_all(&repo_path).unwrap(); + + let changed = rescan_and_merge(&service, &mut status, &repo_path); + assert!(changed); + assert!(status.repos.is_empty()); +} diff --git a/crates/git-same-core/src/monitor/mod.rs b/crates/git-same-core/src/monitor/mod.rs new file mode 100644 index 0000000..448c8c8 --- /dev/null +++ b/crates/git-same-core/src/monitor/mod.rs @@ -0,0 +1,20 @@ +//! Long-running monitor that powers Finder badge updates. +//! +//! The monitor periodically scans configured workspaces, computes badge +//! state, atomically writes `status.json`, and serves a Unix socket so the +//! macOS Finder Sync extension (or other clients) can request refreshes. +//! +//! This module is the single source of truth for the loop. It is consumed +//! by the `gisa monitor` CLI subcommand and is also reusable by a host +//! application (e.g. the Tauri app) that wants to run the monitor in-process +//! instead of as a separate child process. Callers supply a shutdown future +//! so they can wire whichever termination signal makes sense for them +//! (`ctrl_c` + SIGTERM for the CLI, a `tokio::sync::Notify` for a host). + +pub mod incremental; +pub mod owner_classifier; +pub mod run; +#[cfg(unix)] +pub mod socket_handler; + +pub use run::{run, Options}; diff --git a/crates/git-same-core/src/monitor/owner_classifier.rs b/crates/git-same-core/src/monitor/owner_classifier.rs new file mode 100644 index 0000000..88e08de --- /dev/null +++ b/crates/git-same-core/src/monitor/owner_classifier.rs @@ -0,0 +1,136 @@ +//! Background classification of org folder owners (User vs Organization). +//! +//! Spawned once at monitor startup. Walks every configured workspace, +//! resolves any owner names that aren't yet classified via the GitHub API, +//! and persists results in `OwnerTypeCache`. Subsequent scans pick up the +//! new classifications automatically. + +use crate::api::OwnerTypeCache; +use crate::config::{Config, WorkspaceProvider}; +use crate::types::OwnerType; +use std::collections::{BTreeMap, BTreeSet}; +use tracing::{debug, info, warn}; + +/// Spawn the classifier on the current tokio runtime. Returns immediately. +pub fn spawn_owner_classifier(config: Config, cache: OwnerTypeCache) { + tokio::spawn(async move { + // Owners are grouped by provider endpoint: each GitHub instance + // (github.com vs a GitHub Enterprise host) gets its own client and + // classification pass, so a GHE workspace is never queried against + // github.com. + let groups = collect_owner_names_by_provider(&config); + let pending: Vec<(WorkspaceProvider, Vec)> = groups + .into_iter() + .filter_map(|(provider, names)| { + let missing = cache.missing(names.iter().map(|s| s.as_str())); + if missing.is_empty() { + None + } else { + Some((provider, missing)) + } + }) + .collect(); + + if pending.is_empty() { + debug!("Owner type cache already populated, skipping classification"); + return; + } + + let token = match crate::auth::gh_cli::get_token() { + Ok(t) => t, + Err(e) => { + warn!(error = %e, "Owner classification skipped: gh auth token unavailable"); + return; + } + }; + + let total: usize = pending.iter().map(|(_, names)| names.len()).sum(); + info!(count = total, "Classifying owner types via GitHub API"); + + for (ws_provider, names) in pending { + let provider = match crate::provider::create_provider(&ws_provider, &token) { + Ok(p) => p, + Err(e) => { + warn!( + provider = %ws_provider.display_name(), + api_url = %ws_provider.effective_api_url(), + error = %e, + "Owner classification skipped for provider: init failed" + ); + continue; + } + }; + for name in &names { + match provider.get_owner_type(name).await { + Ok(ot) => { + if let Err(e) = cache.set(name, ot) { + warn!(name = %name, error = %e, "Failed to persist owner type"); + } else { + debug!(name = %name, owner_type = ?ot, "Classified owner"); + } + } + Err(e) => { + debug!(name = %name, error = %e, "Owner classification failed, leaving unknown"); + let _ = cache.set(name, OwnerType::Unknown); + } + } + } + } + info!("Owner classification complete"); + }); +} + +/// Collect every unique top-level folder name (orgs + users), grouped by the +/// provider endpoint of the workspace it came from. +/// +/// Workspaces that point at the same GitHub instance (same effective API URL) +/// are merged into one group and classified with a single shared client; +/// distinct endpoints (e.g. a GitHub Enterprise host) each get their own group. +fn collect_owner_names_by_provider(config: &Config) -> Vec<(WorkspaceProvider, Vec)> { + // Keyed by effective API URL so multiple workspaces on the same instance + // collapse together. The first provider seen for a key wins (they are + // equivalent for classification purposes). + let mut groups: BTreeMap)> = BTreeMap::new(); + + for ws_path in &config.workspaces { + let expanded = shellexpand::tilde(ws_path).to_string(); + let root = std::path::PathBuf::from(&expanded); + if !root.exists() { + continue; + } + let ws_config = match crate::config::WorkspaceStore::load(&root) { + Ok(ws) => ws, + Err(_) => continue, + }; + + let key = ws_config.provider.effective_api_url(); + let (_, names) = groups + .entry(key) + .or_insert_with(|| (ws_config.provider.clone(), BTreeSet::new())); + + let base_path = ws_config.expanded_base_path(); + if !ws_config.orgs.is_empty() { + names.extend(ws_config.orgs.iter().cloned()); + } else if let Ok(entries) = std::fs::read_dir(&base_path) { + for e in entries.flatten() { + if let Some(n) = e.file_name().to_str() { + if !n.starts_with('.') && e.file_type().map(|t| t.is_dir()).unwrap_or(false) { + names.insert(n.to_string()); + } + } + } + } + if !ws_config.username.is_empty() { + names.insert(ws_config.username.clone()); + } + } + + groups + .into_values() + .map(|(provider, names)| (provider, names.into_iter().collect())) + .collect() +} + +#[cfg(test)] +#[path = "owner_classifier_tests.rs"] +mod tests; diff --git a/crates/git-same-core/src/monitor/owner_classifier_tests.rs b/crates/git-same-core/src/monitor/owner_classifier_tests.rs new file mode 100644 index 0000000..c7ac054 --- /dev/null +++ b/crates/git-same-core/src/monitor/owner_classifier_tests.rs @@ -0,0 +1,91 @@ +use super::*; +use crate::config::{WorkspaceConfig, WorkspaceProvider}; +use crate::types::ProviderKind; +use std::path::Path; +use tempfile::TempDir; + +/// Write a workspace `config.toml` directly so `WorkspaceStore::load` can read +/// it back, without touching the global registry or `HOME`. +fn write_workspace(root: &Path, provider: WorkspaceProvider, orgs: &[&str]) { + let mut ws = WorkspaceConfig::new_from_root(root); + ws.provider = provider; + ws.orgs = orgs.iter().map(|s| s.to_string()).collect(); + let dot = root.join(".git-same"); + std::fs::create_dir_all(&dot).unwrap(); + std::fs::write(dot.join("config.toml"), ws.to_toml().unwrap()).unwrap(); +} + +fn ghe_provider(api_url: &str) -> WorkspaceProvider { + WorkspaceProvider { + kind: ProviderKind::GitHub, + api_url: Some(api_url.to_string()), + prefer_ssh: true, + } +} + +#[test] +fn groups_owner_names_by_provider_endpoint() { + let dir = TempDir::new().unwrap(); + let dotcom_root = dir.path().join("dotcom"); + let ghe_root = dir.path().join("ghe"); + std::fs::create_dir_all(&dotcom_root).unwrap(); + std::fs::create_dir_all(&ghe_root).unwrap(); + + write_workspace( + &dotcom_root, + WorkspaceProvider::default(), + &["acme", "globex"], + ); + write_workspace( + &ghe_root, + ghe_provider("https://github.example.com/api/v3"), + &["internal-org"], + ); + + let config = Config { + workspaces: vec![ + dotcom_root.to_string_lossy().into_owned(), + ghe_root.to_string_lossy().into_owned(), + ], + ..Default::default() + }; + + let groups = collect_owner_names_by_provider(&config); + assert_eq!(groups.len(), 2, "two distinct provider endpoints"); + + let dotcom_url = WorkspaceProvider::default().effective_api_url(); + let dotcom = groups + .iter() + .find(|(p, _)| p.effective_api_url() == dotcom_url) + .expect("github.com group present"); + assert_eq!(dotcom.1, vec!["acme".to_string(), "globex".to_string()]); + + let ghe = groups + .iter() + .find(|(p, _)| p.effective_api_url() == "https://github.example.com/api/v3") + .expect("GHE group present"); + assert_eq!(ghe.1, vec!["internal-org".to_string()]); +} + +#[test] +fn merges_owners_from_workspaces_on_same_endpoint() { + let dir = TempDir::new().unwrap(); + let a = dir.path().join("a"); + let b = dir.path().join("b"); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + write_workspace(&a, WorkspaceProvider::default(), &["acme"]); + write_workspace(&b, WorkspaceProvider::default(), &["globex"]); + + let config = Config { + workspaces: vec![ + a.to_string_lossy().into_owned(), + b.to_string_lossy().into_owned(), + ], + ..Default::default() + }; + + let groups = collect_owner_names_by_provider(&config); + assert_eq!(groups.len(), 1, "same endpoint collapses to one group"); + assert_eq!(groups[0].1, vec!["acme".to_string(), "globex".to_string()]); +} diff --git a/crates/git-same-core/src/monitor/run.rs b/crates/git-same-core/src/monitor/run.rs new file mode 100644 index 0000000..430a307 --- /dev/null +++ b/crates/git-same-core/src/monitor/run.rs @@ -0,0 +1,333 @@ +//! Monitor loop entry point. +//! +//! Event-driven scans (notify FSEvents + socket REFRESH) are the primary +//! source of updates; a periodic full `scan_all` is kept as a safety net +//! for events `notify` may have dropped and for ambient repos that appear +//! in scan roots without a parent we are subscribed to. The full-scan +//! cadence is controlled by `Options::interval` (in turn driven by the CLI +//! `--interval` flag and `config.monitor.fullscan_interval_secs`). + +use crate::api::{AmbientUpgradeCache, OwnerTypeCache, RepoScanService}; +use crate::config::Config; +use crate::errors::Result; +use crate::git::ShellGit; +use crate::ipc::status_file::ensure_legacy_symlinks; +use crate::ipc::{IpcConfig, StatusFileWriter}; +use crate::monitor::incremental::rescan_and_merge; +use crate::output::Output; +use crate::types::FinderStatus; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use std::collections::HashSet; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tracing::{debug, error, info, warn}; + +use super::owner_classifier::spawn_owner_classifier; + +/// Trailing-edge debounce window for collapsing FSEvent bursts (e.g. a +/// single `git commit` fires many .git/ writes) into one repo rescan. +const FS_EVENT_DEBOUNCE: Duration = Duration::from_millis(750); + +/// Options for [`run`]. +#[derive(Debug, Clone)] +pub struct Options { + /// Cadence of the safety-net full `scan_all`. Most updates flow through + /// the FSEvents arm; this timer covers dropped events and catches + /// ambient repos that appear without a parent we subscribed to. + pub interval: Duration, + /// Resolved IPC paths (status file + socket). + pub ipc_config: IpcConfig, +} + +/// Run the monitor loop until `shutdown` resolves. +pub async fn run(config: &Config, output: &Output, opts: Options, shutdown: S) -> Result<()> +where + S: Future, +{ + let Options { + interval, + ipc_config, + } = opts; + + ipc_config.ensure_dir()?; + + if let Err(e) = ensure_legacy_symlinks(&ipc_config.dir) { + warn!("Could not refresh legacy IPC symlinks: {}", e); + } + + info!("Starting git-same monitor"); + output.info("Starting git-same monitor..."); + + let status_writer = StatusFileWriter::new(ipc_config.status_file_path()); + let git = ShellGit::new(); + + let owner_types = OwnerTypeCache::load(OwnerTypeCache::default_path(&ipc_config.dir)); + let ambient_upgrades = AmbientUpgradeCache::new(); + let service = RepoScanService::new(&git, config) + .with_owner_types(owner_types.clone()) + .with_ambient_upgrades(ambient_upgrades.clone()); + spawn_owner_classifier(config.clone(), owner_types); + + let pid = std::process::id(); + + let initial_status = service.scan_all(pid)?; + status_writer.write(&initial_status)?; + let ambient_count = initial_status + .repos + .iter() + .filter(|r| r.workspace.is_none()) + .count(); + let workspace_count = initial_status.repos.len() - ambient_count; + info!( + repos = initial_status.repos.len(), + workspace = workspace_count, + ambient = ambient_count, + "Initial scan complete, status written" + ); + output.info(&format!( + "Monitoring {} repos ({} workspace, {} ambient). Status: {}", + initial_status.repos.len(), + workspace_count, + ambient_count, + ipc_config.status_file_path().display() + )); + + let watched_roots = collect_watched_roots(config, &initial_status); + reapply_workspace_folder_icons(config, &initial_status); + let shared_status = Arc::new(Mutex::new(initial_status)); + + #[cfg(unix)] + let socket_listener = crate::ipc::UnixSocketListener::new(ipc_config.socket_path()); + #[cfg(unix)] + let tokio_listener = socket_listener.bind().await?; + + let (fs_tx, mut fs_rx) = tokio::sync::mpsc::unbounded_channel::(); + let _watcher = match start_filesystem_watcher(&watched_roots, fs_tx) { + Ok(watcher) => Some(watcher), + Err(e) => { + warn!(error = %e, "Filesystem watcher failed to start; monitor will only respond to REFRESH commands"); + None + } + }; + + let mut pending: HashSet = HashSet::new(); + + tokio::pin!(shutdown); + + loop { + let debounce_active = !pending.is_empty(); + + #[cfg(unix)] + { + tokio::select! { + _ = tokio::time::sleep(FS_EVENT_DEBOUNCE), if debounce_active => { + flush_pending(&service, &shared_status, &status_writer, &ambient_upgrades, &mut pending); + }, + _ = tokio::time::sleep(interval) => { + debug!("Safety-net full scan"); + match service.scan_all(pid) { + Ok(new_status) => { + reapply_workspace_folder_icons(config, &new_status); + let mut status = shared_status.lock().expect("status mutex poisoned"); + *status = new_status; + if let Err(e) = status_writer.write(&status) { + error!(error = %e, "Failed to write status file after full scan"); + } else { + debug!(repos = status.repos.len(), "Full scan complete"); + } + } + Err(e) => { + error!(error = %e, "Full scan failed"); + } + } + }, + Some(repo_path) = fs_rx.recv() => { + pending.insert(repo_path); + }, + result = tokio_listener.accept() => { + match result { + Ok((stream, _)) => { + let config_clone = config.clone(); + let writer_path = status_writer.path().to_path_buf(); + let owner_clone = service.owner_types_clone(); + let ambient_clone = service.ambient_upgrades_clone(); + let status_clone = shared_status.clone(); + tokio::spawn(async move { + super::socket_handler::handle_socket_connection( + stream, + &config_clone, + pid, + &writer_path, + status_clone, + owner_clone, + ambient_clone, + ).await; + }); + } + Err(e) => { + warn!(error = %e, "Failed to accept socket connection"); + } + } + }, + _ = &mut shutdown => { + info!("Monitor shutting down"); + output.info("Monitor shutting down..."); + socket_listener.cleanup(); + break; + }, + } + } + + #[cfg(not(unix))] + { + tokio::select! { + _ = tokio::time::sleep(FS_EVENT_DEBOUNCE), if debounce_active => { + flush_pending(&service, &shared_status, &status_writer, &ambient_upgrades, &mut pending); + }, + _ = tokio::time::sleep(interval) => { + debug!("Safety-net full scan"); + if let Ok(new_status) = service.scan_all(pid) { + reapply_workspace_folder_icons(config, &new_status); + let mut status = shared_status.lock().expect("status mutex poisoned"); + *status = new_status; + let _ = status_writer.write(&status); + } + }, + Some(repo_path) = fs_rx.recv() => { + pending.insert(repo_path); + }, + _ = &mut shutdown => { + info!("Monitor shutting down"); + output.info("Monitor shutting down..."); + break; + }, + } + } + } + + let _ = ambient_upgrades; + Ok(()) +} + +fn flush_pending( + service: &RepoScanService<'_>, + shared_status: &Arc>, + status_writer: &StatusFileWriter, + ambient_upgrades: &AmbientUpgradeCache, + pending: &mut HashSet, +) { + if pending.is_empty() { + return; + } + let mut any_changed = false; + let mut status = shared_status.lock().expect("status mutex poisoned"); + for repo in pending.drain() { + if rescan_and_merge(service, &mut status, &repo) { + any_changed = true; + if let Some(entry) = status.repos.iter().find(|r| r.path == repo).cloned() { + ambient_upgrades.set(repo, entry); + } + } + } + if any_changed { + if let Err(e) = status_writer.write(&status) { + error!(error = %e, "Failed to write status file after rescan"); + } else { + debug!(repos = status.repos.len(), "Incremental status written"); + } + } +} + +/// Idempotently repaint the Git-Same folder icon on every workspace root in +/// `status`. Skips roots whose `Icon\r` file is already present (the normal +/// case) so the hot path is one stat per workspace. Recovers gracefully if +/// the user manually deleted the icon. Opt-out via +/// `[ui] custom_folder_icon = false`. +fn reapply_workspace_folder_icons(config: &Config, status: &FinderStatus) { + if !config.ui.custom_folder_icon { + return; + } + for ws in &status.workspaces { + if crate::macos::folder_icon::is_set(&ws.root) { + continue; + } + crate::macos::folder_icon::set_or_log( + &ws.root, + crate::macos::folder_icon::WORKSPACE_FOLDER_ICNS, + ); + } +} + +fn collect_watched_roots(config: &Config, status: &FinderStatus) -> Vec { + let mut roots: Vec = Vec::new(); + for ws in &status.workspaces { + let canonical = std::fs::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone()); + if !roots.contains(&canonical) { + roots.push(canonical); + } + } + if config.finder.show_ambient { + for raw in &config.finder.scan_roots { + let expanded = shellexpand::tilde(raw).to_string(); + let path = PathBuf::from(expanded); + if !path.exists() { + continue; + } + let canonical = std::fs::canonicalize(&path).unwrap_or(path); + if !roots.contains(&canonical) { + roots.push(canonical); + } + } + } + roots +} + +fn start_filesystem_watcher( + roots: &[PathBuf], + tx: tokio::sync::mpsc::UnboundedSender, +) -> Result { + let watch_roots: Vec = roots.to_vec(); + let mut watcher = RecommendedWatcher::new( + move |res: notify::Result| { + let event = match res { + Ok(event) => event, + Err(e) => { + debug!(error = %e, "notify error"); + return; + } + }; + for raw_path in event.paths { + let canonical = + std::fs::canonicalize(&raw_path).unwrap_or_else(|_| raw_path.clone()); + if let Some(repo) = enclosing_repo(&canonical, &watch_roots) { + let _ = tx.send(repo); + } + } + }, + notify::Config::default(), + ) + .map_err(|e| crate::errors::AppError::config(format!("notify watcher init failed: {e}")))?; + for root in roots { + if let Err(e) = watcher.watch(root, RecursiveMode::Recursive) { + warn!(path = %root.display(), error = %e, "Failed to watch root"); + } + } + Ok(watcher) +} + +/// Walk up from `path` until a `.git` directory is found, stopping at the +/// parent of any watched root. Returns the repo's working-tree path. +fn enclosing_repo(path: &Path, watched_roots: &[PathBuf]) -> Option { + let mut current = path; + loop { + if current.join(".git").exists() { + return Some(current.to_path_buf()); + } + if watched_roots.iter().any(|r| r == current) { + return None; + } + current = current.parent()?; + } +} diff --git a/crates/git-same-core/src/monitor/socket_handler.rs b/crates/git-same-core/src/monitor/socket_handler.rs new file mode 100644 index 0000000..fdb20c3 --- /dev/null +++ b/crates/git-same-core/src/monitor/socket_handler.rs @@ -0,0 +1,116 @@ +//! Handles a single connection on the monitor's Unix socket. +//! +//! Each accepted connection is text-line based: read one line, dispatch +//! the corresponding `DaemonCommand`, write a one-line response. + +use crate::api::{AmbientUpgradeCache, OwnerTypeCache, RepoScanService}; +use crate::config::Config; +use crate::git::ShellGit; +use crate::ipc::unix_socket::DaemonCommand; +use crate::ipc::StatusFileWriter; +use crate::monitor::incremental::rescan_and_merge; +use crate::types::FinderStatus; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tracing::{debug, error}; + +/// Read one command from `stream`, run it against the live state, write +/// the response, and close. Errors are logged and swallowed; a misbehaving +/// client must not take the monitor down. +pub async fn handle_socket_connection( + mut stream: UnixStream, + config: &Config, + pid: u32, + status_path: &Path, + shared_status: Arc>, + owner_types: Option, + ambient_upgrades: Option, +) { + let (reader, mut writer) = stream.split(); + let mut reader = BufReader::new(reader); + let mut line = String::new(); + + match reader.read_line(&mut line).await { + Ok(0) => return, + Ok(_) => {} + Err(e) => { + debug!(error = %e, "Failed to read from socket"); + return; + } + } + + let cmd = DaemonCommand::parse(&line); + let git = ShellGit::new(); + let mut service = RepoScanService::new(&git, config); + if let Some(cache) = owner_types { + service = service.with_owner_types(cache); + } + if let Some(cache) = ambient_upgrades.clone() { + service = service.with_ambient_upgrades(cache); + } + + let response = match cmd { + DaemonCommand::Ping => "PONG\n".to_string(), + DaemonCommand::Refresh(ref path) => { + let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone()); + debug!(path = %canonical.display(), "Refresh requested"); + let mut status = shared_status.lock().expect("status mutex poisoned"); + let changed = rescan_and_merge(&service, &mut status, &canonical); + if changed { + let file_writer = StatusFileWriter::new(status_path.to_path_buf()); + if let Err(e) = file_writer.write(&status) { + error!(error = %e, "Failed to write status file after Refresh"); + } + } + if let (Some(cache), Some(entry)) = ( + ambient_upgrades.as_ref(), + status.repos.iter().find(|r| r.path == canonical).cloned(), + ) { + cache.set(canonical, entry); + } + "OK\n".to_string() + } + DaemonCommand::RefreshAll => match service.scan_all(pid) { + Ok(new_status) => { + let mut status = shared_status.lock().expect("status mutex poisoned"); + *status = new_status; + let file_writer = StatusFileWriter::new(status_path.to_path_buf()); + if let Err(e) = file_writer.write(&status) { + error!(error = %e, "Failed to write status file after RefreshAll"); + } + "OK\n".to_string() + } + Err(e) => { + error!(error = %e, "Refresh failed"); + "ERROR\n".to_string() + } + }, + DaemonCommand::Status => status_response(status_path), + DaemonCommand::Unknown(cmd) => { + format!("UNKNOWN: {}\n", cmd) + } + }; + + let _ = writer.write_all(response.as_bytes()).await; + let _ = writer.flush().await; +} + +/// Build the response for a `STATUS` command: the current status file as +/// pretty JSON terminated by a newline so it matches the line-framed protocol +/// (`PONG\n`, `OK\n`, `ERROR\n`). Returns `ERROR\n` if the file can't be read +/// or serialized. +fn status_response(status_path: &Path) -> String { + let file_writer = StatusFileWriter::new(status_path.to_path_buf()); + match file_writer.read() { + Ok(status) => serde_json::to_string_pretty(&status) + .map(|s| format!("{s}\n")) + .unwrap_or_else(|_| "ERROR\n".to_string()), + Err(_) => "ERROR\n".to_string(), + } +} + +#[cfg(test)] +#[path = "socket_handler_tests.rs"] +mod tests; diff --git a/crates/git-same-core/src/monitor/socket_handler_tests.rs b/crates/git-same-core/src/monitor/socket_handler_tests.rs new file mode 100644 index 0000000..858b627 --- /dev/null +++ b/crates/git-same-core/src/monitor/socket_handler_tests.rs @@ -0,0 +1,23 @@ +use super::*; +use tempfile::TempDir; + +#[test] +fn status_response_ends_with_newline() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("status.json"); + let writer = StatusFileWriter::new(path.clone()); + writer + .write(&FinderStatus::new(0, "2026-06-21T00:00:00Z".to_string())) + .unwrap(); + + let resp = status_response(&path); + assert!(resp.ends_with('\n'), "Status response must end with newline"); + assert_ne!(resp, "ERROR\n"); +} + +#[test] +fn status_response_error_when_missing() { + let dir = TempDir::new().unwrap(); + let resp = status_response(&dir.path().join("does-not-exist.json")); + assert_eq!(resp, "ERROR\n"); +} diff --git a/src/operations/clone.rs b/crates/git-same-core/src/operations/clone.rs similarity index 98% rename from src/operations/clone.rs rename to crates/git-same-core/src/operations/clone.rs index ced17e2..1384733 100644 --- a/src/operations/clone.rs +++ b/crates/git-same-core/src/operations/clone.rs @@ -6,8 +6,8 @@ //! # Example //! //! ```no_run -//! use git_same::operations::clone::{CloneManager, CloneManagerOptions, NoProgress}; -//! use git_same::git::ShellGit; +//! use git_same_core::operations::clone::{CloneManager, CloneManagerOptions, NoProgress}; +//! use git_same_core::git::ShellGit; //! use std::path::Path; //! //! # async fn example() { diff --git a/src/operations/clone_tests.rs b/crates/git-same-core/src/operations/clone_tests.rs similarity index 100% rename from src/operations/clone_tests.rs rename to crates/git-same-core/src/operations/clone_tests.rs diff --git a/src/operations/mod.rs b/crates/git-same-core/src/operations/mod.rs similarity index 100% rename from src/operations/mod.rs rename to crates/git-same-core/src/operations/mod.rs diff --git a/src/operations/sync.rs b/crates/git-same-core/src/operations/sync.rs similarity index 99% rename from src/operations/sync.rs rename to crates/git-same-core/src/operations/sync.rs index c928c0c..6cd725e 100644 --- a/src/operations/sync.rs +++ b/crates/git-same-core/src/operations/sync.rs @@ -6,9 +6,9 @@ //! # Example //! //! ```no_run -//! use git_same::operations::sync::{SyncManager, SyncManagerOptions, SyncMode, LocalRepo, NoSyncProgress}; -//! use git_same::git::ShellGit; -//! use git_same::types::{OwnedRepo, Repo}; +//! use git_same_core::operations::sync::{SyncManager, SyncManagerOptions, SyncMode, LocalRepo, NoSyncProgress}; +//! use git_same_core::git::ShellGit; +//! use git_same_core::types::{OwnedRepo, Repo}; //! use std::path::PathBuf; //! //! # async fn example() { diff --git a/src/operations/sync_tests.rs b/crates/git-same-core/src/operations/sync_tests.rs similarity index 100% rename from src/operations/sync_tests.rs rename to crates/git-same-core/src/operations/sync_tests.rs diff --git a/src/output/mod.rs b/crates/git-same-core/src/output/mod.rs similarity index 100% rename from src/output/mod.rs rename to crates/git-same-core/src/output/mod.rs diff --git a/src/output/printer.rs b/crates/git-same-core/src/output/printer.rs similarity index 100% rename from src/output/printer.rs rename to crates/git-same-core/src/output/printer.rs diff --git a/src/output/printer_tests.rs b/crates/git-same-core/src/output/printer_tests.rs similarity index 100% rename from src/output/printer_tests.rs rename to crates/git-same-core/src/output/printer_tests.rs diff --git a/src/output/progress/clone.rs b/crates/git-same-core/src/output/progress/clone.rs similarity index 100% rename from src/output/progress/clone.rs rename to crates/git-same-core/src/output/progress/clone.rs diff --git a/src/output/progress/clone_tests.rs b/crates/git-same-core/src/output/progress/clone_tests.rs similarity index 100% rename from src/output/progress/clone_tests.rs rename to crates/git-same-core/src/output/progress/clone_tests.rs diff --git a/src/output/progress/discovery.rs b/crates/git-same-core/src/output/progress/discovery.rs similarity index 100% rename from src/output/progress/discovery.rs rename to crates/git-same-core/src/output/progress/discovery.rs diff --git a/src/output/progress/discovery_tests.rs b/crates/git-same-core/src/output/progress/discovery_tests.rs similarity index 100% rename from src/output/progress/discovery_tests.rs rename to crates/git-same-core/src/output/progress/discovery_tests.rs diff --git a/src/output/progress/mod.rs b/crates/git-same-core/src/output/progress/mod.rs similarity index 100% rename from src/output/progress/mod.rs rename to crates/git-same-core/src/output/progress/mod.rs diff --git a/src/output/progress/styles.rs b/crates/git-same-core/src/output/progress/styles.rs similarity index 100% rename from src/output/progress/styles.rs rename to crates/git-same-core/src/output/progress/styles.rs diff --git a/src/output/progress/sync.rs b/crates/git-same-core/src/output/progress/sync.rs similarity index 100% rename from src/output/progress/sync.rs rename to crates/git-same-core/src/output/progress/sync.rs diff --git a/src/output/progress/sync_tests.rs b/crates/git-same-core/src/output/progress/sync_tests.rs similarity index 100% rename from src/output/progress/sync_tests.rs rename to crates/git-same-core/src/output/progress/sync_tests.rs diff --git a/crates/git-same-core/src/progress.rs b/crates/git-same-core/src/progress.rs new file mode 100644 index 0000000..19fb942 --- /dev/null +++ b/crates/git-same-core/src/progress.rs @@ -0,0 +1,247 @@ +//! Host-neutral progress events and adapters. +//! +//! The engine exposes operation-specific progress traits for discovery, clone, +//! and sync. This module gives hosts a single serializable event stream without +//! replacing those traits, so CLIs, TUIs, and GUI hosts can choose their own +//! presentation layer. + +use crate::git::{FetchResult, PullResult}; +use crate::operations::clone::CloneProgress; +use crate::operations::sync::SyncProgress; +use crate::provider::DiscoveryProgress; +use crate::types::OwnedRepo; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::Arc; + +/// A serializable progress event emitted by discovery, clone, and sync work. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ProgressEvent { + /// Discovery found the list of organizations it will scan. + DiscoveryOrgsDiscovered { count: usize }, + /// Discovery started fetching repositories for an organization. + DiscoveryOrgStarted { org_name: String }, + /// Discovery finished fetching repositories for an organization. + DiscoveryOrgComplete { org_name: String, repo_count: usize }, + /// Discovery started fetching personal repositories. + DiscoveryPersonalReposStarted, + /// Discovery finished fetching personal repositories. + DiscoveryPersonalReposComplete { count: usize }, + /// Discovery encountered a non-fatal error. + DiscoveryError { message: String }, + /// A clone operation started. + CloneStarted { + repo_name: String, + index: usize, + total: usize, + }, + /// A clone operation completed. + CloneCompleted { + repo_name: String, + index: usize, + total: usize, + }, + /// A clone operation failed. + CloneFailed { + repo_name: String, + error: String, + index: usize, + total: usize, + }, + /// A clone operation was skipped. + CloneSkipped { + repo_name: String, + reason: String, + index: usize, + total: usize, + }, + /// A sync operation started. + SyncStarted { + repo_name: String, + path: String, + index: usize, + total: usize, + }, + /// A fetch operation completed during sync. + SyncFetched { + repo_name: String, + updated: bool, + new_commits: Option, + index: usize, + total: usize, + }, + /// A pull operation completed during sync. + SyncPulled { + repo_name: String, + success: bool, + updated: bool, + fast_forward: bool, + error: Option, + index: usize, + total: usize, + }, + /// A sync operation failed. + SyncFailed { + repo_name: String, + error: String, + index: usize, + total: usize, + }, + /// A sync operation was skipped. + SyncSkipped { + repo_name: String, + reason: String, + index: usize, + total: usize, + }, +} + +/// A cloneable progress adapter that forwards all events to a host-provided sink. +#[derive(Clone)] +pub struct ProgressReporter { + sink: Arc, +} + +impl ProgressReporter { + /// Creates a reporter that forwards each progress event to `sink`. + pub fn new(sink: impl Fn(ProgressEvent) + Send + Sync + 'static) -> Self { + Self { + sink: Arc::new(sink), + } + } + + fn emit(&self, event: ProgressEvent) { + (self.sink)(event); + } +} + +impl DiscoveryProgress for ProgressReporter { + fn on_orgs_discovered(&self, count: usize) { + self.emit(ProgressEvent::DiscoveryOrgsDiscovered { count }); + } + + fn on_org_started(&self, org_name: &str) { + self.emit(ProgressEvent::DiscoveryOrgStarted { + org_name: org_name.to_string(), + }); + } + + fn on_org_complete(&self, org_name: &str, repo_count: usize) { + self.emit(ProgressEvent::DiscoveryOrgComplete { + org_name: org_name.to_string(), + repo_count, + }); + } + + fn on_personal_repos_started(&self) { + self.emit(ProgressEvent::DiscoveryPersonalReposStarted); + } + + fn on_personal_repos_complete(&self, count: usize) { + self.emit(ProgressEvent::DiscoveryPersonalReposComplete { count }); + } + + fn on_error(&self, message: &str) { + self.emit(ProgressEvent::DiscoveryError { + message: message.to_string(), + }); + } +} + +impl CloneProgress for ProgressReporter { + fn on_start(&self, repo: &OwnedRepo, index: usize, total: usize) { + self.emit(ProgressEvent::CloneStarted { + repo_name: repo.full_name().to_string(), + index, + total, + }); + } + + fn on_complete(&self, repo: &OwnedRepo, index: usize, total: usize) { + self.emit(ProgressEvent::CloneCompleted { + repo_name: repo.full_name().to_string(), + index, + total, + }); + } + + fn on_error(&self, repo: &OwnedRepo, error: &str, index: usize, total: usize) { + self.emit(ProgressEvent::CloneFailed { + repo_name: repo.full_name().to_string(), + error: error.to_string(), + index, + total, + }); + } + + fn on_skip(&self, repo: &OwnedRepo, reason: &str, index: usize, total: usize) { + self.emit(ProgressEvent::CloneSkipped { + repo_name: repo.full_name().to_string(), + reason: reason.to_string(), + index, + total, + }); + } +} + +impl SyncProgress for ProgressReporter { + fn on_start(&self, repo: &OwnedRepo, path: &Path, index: usize, total: usize) { + self.emit(ProgressEvent::SyncStarted { + repo_name: repo.full_name().to_string(), + path: path.display().to_string(), + index, + total, + }); + } + + fn on_fetch_complete( + &self, + repo: &OwnedRepo, + result: &FetchResult, + index: usize, + total: usize, + ) { + self.emit(ProgressEvent::SyncFetched { + repo_name: repo.full_name().to_string(), + updated: result.updated, + new_commits: result.new_commits, + index, + total, + }); + } + + fn on_pull_complete(&self, repo: &OwnedRepo, result: &PullResult, index: usize, total: usize) { + self.emit(ProgressEvent::SyncPulled { + repo_name: repo.full_name().to_string(), + success: result.success, + updated: result.updated, + fast_forward: result.fast_forward, + error: result.error.clone(), + index, + total, + }); + } + + fn on_error(&self, repo: &OwnedRepo, error: &str, index: usize, total: usize) { + self.emit(ProgressEvent::SyncFailed { + repo_name: repo.full_name().to_string(), + error: error.to_string(), + index, + total, + }); + } + + fn on_skip(&self, repo: &OwnedRepo, reason: &str, index: usize, total: usize) { + self.emit(ProgressEvent::SyncSkipped { + repo_name: repo.full_name().to_string(), + reason: reason.to_string(), + index, + total, + }); + } +} + +#[cfg(test)] +#[path = "progress_tests.rs"] +mod tests; diff --git a/crates/git-same-core/src/progress_tests.rs b/crates/git-same-core/src/progress_tests.rs new file mode 100644 index 0000000..b5470f9 --- /dev/null +++ b/crates/git-same-core/src/progress_tests.rs @@ -0,0 +1,155 @@ +use super::*; +use crate::git::{FetchResult, PullResult}; +use crate::operations::clone::CloneProgress; +use crate::operations::sync::SyncProgress; +use crate::provider::DiscoveryProgress; +use crate::types::{OwnedRepo, Repo}; +use std::sync::{Arc, Mutex}; + +fn sample_repo() -> OwnedRepo { + OwnedRepo::new("acme", Repo::test("rocket", "acme")) +} + +fn reporter_with_events() -> (ProgressReporter, Arc>>) { + let events = Arc::new(Mutex::new(Vec::new())); + let captured = events.clone(); + let reporter = ProgressReporter::new(move |event| { + captured.lock().unwrap().push(event); + }); + (reporter, events) +} + +#[test] +fn discovery_progress_emits_serializable_events() { + let (reporter, events) = reporter_with_events(); + + DiscoveryProgress::on_orgs_discovered(&reporter, 2); + DiscoveryProgress::on_org_started(&reporter, "acme"); + DiscoveryProgress::on_org_complete(&reporter, "acme", 3); + DiscoveryProgress::on_personal_repos_started(&reporter); + DiscoveryProgress::on_personal_repos_complete(&reporter, 1); + DiscoveryProgress::on_error(&reporter, "rate limited"); + + let events = events.lock().unwrap(); + assert_eq!( + events.as_slice(), + [ + ProgressEvent::DiscoveryOrgsDiscovered { count: 2 }, + ProgressEvent::DiscoveryOrgStarted { + org_name: "acme".to_string(), + }, + ProgressEvent::DiscoveryOrgComplete { + org_name: "acme".to_string(), + repo_count: 3, + }, + ProgressEvent::DiscoveryPersonalReposStarted, + ProgressEvent::DiscoveryPersonalReposComplete { count: 1 }, + ProgressEvent::DiscoveryError { + message: "rate limited".to_string(), + }, + ] + ); + assert!(serde_json::to_string(&events[0]) + .unwrap() + .contains("discovery_orgs_discovered")); +} + +#[test] +fn clone_progress_emits_repo_events() { + let repo = sample_repo(); + let (reporter, events) = reporter_with_events(); + + CloneProgress::on_start(&reporter, &repo, 0, 4); + CloneProgress::on_complete(&reporter, &repo, 1, 4); + CloneProgress::on_error(&reporter, &repo, "failed", 2, 4); + CloneProgress::on_skip(&reporter, &repo, "exists", 3, 4); + + let events = events.lock().unwrap(); + assert_eq!( + events.as_slice(), + [ + ProgressEvent::CloneStarted { + repo_name: "acme/rocket".to_string(), + index: 0, + total: 4, + }, + ProgressEvent::CloneCompleted { + repo_name: "acme/rocket".to_string(), + index: 1, + total: 4, + }, + ProgressEvent::CloneFailed { + repo_name: "acme/rocket".to_string(), + error: "failed".to_string(), + index: 2, + total: 4, + }, + ProgressEvent::CloneSkipped { + repo_name: "acme/rocket".to_string(), + reason: "exists".to_string(), + index: 3, + total: 4, + }, + ] + ); +} + +#[test] +fn sync_progress_emits_fetch_and_pull_details() { + let repo = sample_repo(); + let (reporter, events) = reporter_with_events(); + let path = std::path::Path::new("/tmp/acme/rocket"); + + SyncProgress::on_start(&reporter, &repo, path, 0, 3); + SyncProgress::on_fetch_complete( + &reporter, + &repo, + &FetchResult { + updated: true, + new_commits: Some(2), + }, + 1, + 3, + ); + SyncProgress::on_pull_complete( + &reporter, + &repo, + &PullResult { + success: true, + updated: true, + fast_forward: true, + error: None, + }, + 2, + 3, + ); + + let events = events.lock().unwrap(); + assert_eq!( + events.as_slice(), + [ + ProgressEvent::SyncStarted { + repo_name: "acme/rocket".to_string(), + path: "/tmp/acme/rocket".to_string(), + index: 0, + total: 3, + }, + ProgressEvent::SyncFetched { + repo_name: "acme/rocket".to_string(), + updated: true, + new_commits: Some(2), + index: 1, + total: 3, + }, + ProgressEvent::SyncPulled { + repo_name: "acme/rocket".to_string(), + success: true, + updated: true, + fast_forward: true, + error: None, + index: 2, + total: 3, + }, + ] + ); +} diff --git a/src/provider/github/client.rs b/crates/git-same-core/src/provider/github/client.rs similarity index 93% rename from src/provider/github/client.rs rename to crates/git-same-core/src/provider/github/client.rs index 77b4ecc..daba63c 100644 --- a/src/provider/github/client.rs +++ b/crates/git-same-core/src/provider/github/client.rs @@ -9,7 +9,7 @@ use super::pagination::fetch_all_pages; use super::GITHUB_API_URL; use crate::errors::ProviderError; use crate::provider::traits::*; -use crate::types::{Org, OwnedRepo, ProviderKind, Repo}; +use crate::types::{Org, OwnedRepo, OwnerType, ProviderKind, Repo}; /// Default timeout for API requests in seconds. const DEFAULT_TIMEOUT_SECS: u64 = 60; @@ -267,6 +267,22 @@ impl Provider for GitHubProvider { repo.clone_url.clone() } } + + async fn get_owner_type(&self, name: &str) -> Result { + #[derive(serde::Deserialize)] + struct UserOrOrg { + #[serde(rename = "type")] + kind: String, + } + + let url = self.api_url(&format!("/users/{}", name)); + let payload: UserOrOrg = self.get(&url).await?; + Ok(match payload.kind.as_str() { + "User" => OwnerType::User, + "Organization" => OwnerType::Organization, + _ => OwnerType::Unknown, + }) + } } #[cfg(test)] diff --git a/src/provider/github/client_tests.rs b/crates/git-same-core/src/provider/github/client_tests.rs similarity index 100% rename from src/provider/github/client_tests.rs rename to crates/git-same-core/src/provider/github/client_tests.rs diff --git a/src/provider/github/mod.rs b/crates/git-same-core/src/provider/github/mod.rs similarity index 100% rename from src/provider/github/mod.rs rename to crates/git-same-core/src/provider/github/mod.rs diff --git a/src/provider/github/pagination.rs b/crates/git-same-core/src/provider/github/pagination.rs similarity index 78% rename from src/provider/github/pagination.rs rename to crates/git-same-core/src/provider/github/pagination.rs index 52a831a..3e61fbe 100644 --- a/src/provider/github/pagination.rs +++ b/crates/git-same-core/src/provider/github/pagination.rs @@ -6,7 +6,7 @@ use reqwest::header::AUTHORIZATION; use reqwest::Client; use serde::de::DeserializeOwned; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use crate::errors::ProviderError; @@ -19,6 +19,12 @@ const MAX_RETRIES: u32 = 3; /// Initial backoff in ms. Doubles each retry: 1s -> 2s -> 4s. const INITIAL_BACKOFF_MS: u64 = 1000; +/// Hard wall-clock budget for a full paginated fetch. +/// +/// Retries, rate-limit sleeps, and per-page requests all count against this +/// budget. Prevents discovery from running for an unbounded amount of time. +pub(crate) const PAGINATION_DEADLINE: Duration = Duration::from_secs(300); + /// Parses the GitHub Link header to find the next page URL. /// /// GitHub Link headers look like: @@ -89,6 +95,7 @@ pub async fn fetch_all_pages( token: &str, initial_url: &str, ) -> Result, ProviderError> { + let deadline = Instant::now() + PAGINATION_DEADLINE; let mut results = Vec::new(); let mut url = Some(format!( "{}{}per_page=100", @@ -103,6 +110,14 @@ pub async fn fetch_all_pages( let mut backoff_ms = INITIAL_BACKOFF_MS; let (next_url_opt, items) = loop { + if Instant::now() >= deadline { + return Err(ProviderError::Network(format!( + "Pagination exceeded {}s budget for '{}'", + PAGINATION_DEADLINE.as_secs(), + initial_url + ))); + } + let response = match client .get(¤t_url) .header(AUTHORIZATION, format!("Bearer {}", token)) @@ -131,13 +146,25 @@ pub async fn fetch_all_pages( .and_then(|h| h.to_str().ok()) .unwrap_or("unknown"); - // Try to parse reset time and wait + // Try to parse reset time and wait, but only if the + // reset window fits inside the remaining budget. if let Some(wait_time) = calculate_wait_time(reset) { - if retry_count < MAX_RETRIES { + let wait_with_buffer = wait_time + Duration::from_secs(5); + let fits_budget = Instant::now() + wait_with_buffer < deadline; + if retry_count < MAX_RETRIES && fits_budget { retry_count += 1; - // Add a small buffer to the wait time - let wait_with_buffer = wait_time + Duration::from_secs(5); tokio::time::sleep(wait_with_buffer).await; + // The sleep can overrun its budget (scheduler + // delay, system suspend); bail immediately + // rather than issue a request already past the + // deadline. + if Instant::now() >= deadline { + return Err(ProviderError::Network(format!( + "Pagination exceeded {}s budget for '{}'", + PAGINATION_DEADLINE.as_secs(), + initial_url + ))); + } continue; // Retry the request } } diff --git a/src/provider/github/pagination_tests.rs b/crates/git-same-core/src/provider/github/pagination_tests.rs similarity index 100% rename from src/provider/github/pagination_tests.rs rename to crates/git-same-core/src/provider/github/pagination_tests.rs diff --git a/src/provider/mock.rs b/crates/git-same-core/src/provider/mock.rs similarity index 97% rename from src/provider/mock.rs rename to crates/git-same-core/src/provider/mock.rs index 18478eb..c051a78 100644 --- a/src/provider/mock.rs +++ b/crates/git-same-core/src/provider/mock.rs @@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex}; use super::traits::*; use crate::errors::ProviderError; -use crate::types::{Org, OwnedRepo, ProviderKind, Repo}; +use crate::types::{Org, OwnedRepo, OwnerType, ProviderKind, Repo}; /// A mock provider that can be configured with predefined responses. pub struct MockProvider { @@ -239,6 +239,10 @@ impl Provider for MockProvider { repo.clone_url.clone() } } + + async fn get_owner_type(&self, _name: &str) -> Result { + Ok(OwnerType::Organization) + } } #[cfg(test)] diff --git a/src/provider/mock_tests.rs b/crates/git-same-core/src/provider/mock_tests.rs similarity index 100% rename from src/provider/mock_tests.rs rename to crates/git-same-core/src/provider/mock_tests.rs diff --git a/src/provider/mod.rs b/crates/git-same-core/src/provider/mod.rs similarity index 89% rename from src/provider/mod.rs rename to crates/git-same-core/src/provider/mod.rs index 5797c4a..6ae5e21 100644 --- a/src/provider/mod.rs +++ b/crates/git-same-core/src/provider/mod.rs @@ -12,10 +12,10 @@ //! # Example //! //! ```no_run -//! use git_same::provider::{create_provider, DiscoveryOptions, NoProgress}; -//! use git_same::config::WorkspaceProvider; +//! use git_same_core::provider::{create_provider, DiscoveryOptions, NoProgress}; +//! use git_same_core::config::WorkspaceProvider; //! -//! # async fn example() -> Result<(), git_same::errors::AppError> { +//! # async fn example() -> Result<(), git_same_core::errors::AppError> { //! let provider = WorkspaceProvider::default(); //! let p = create_provider(&provider, "ghp_token123")?; //! diff --git a/src/provider/mod_tests.rs b/crates/git-same-core/src/provider/mod_tests.rs similarity index 100% rename from src/provider/mod_tests.rs rename to crates/git-same-core/src/provider/mod_tests.rs diff --git a/src/provider/traits.rs b/crates/git-same-core/src/provider/traits.rs similarity index 94% rename from src/provider/traits.rs rename to crates/git-same-core/src/provider/traits.rs index b4612a4..4d018d1 100644 --- a/src/provider/traits.rs +++ b/crates/git-same-core/src/provider/traits.rs @@ -6,7 +6,7 @@ use async_trait::async_trait; use crate::errors::ProviderError; -use crate::types::{Org, OwnedRepo, ProviderKind, Repo}; +use crate::types::{Org, OwnedRepo, OwnerType, ProviderKind, Repo}; /// Authentication credentials for a provider. #[derive(Debug, Clone)] @@ -207,6 +207,11 @@ pub trait Provider: Send + Sync { /// Returns the clone URL for a repo (SSH or HTTPS based on preference). fn get_clone_url(&self, repo: &Repo, prefer_ssh: bool) -> String; + + /// Classifies whether the given account name is a personal user or an + /// organization. Used by the Finder badge monitor to pick between "U" and + /// "O" badges on workspace folders. + async fn get_owner_type(&self, name: &str) -> Result; } #[cfg(test)] diff --git a/src/provider/traits_tests.rs b/crates/git-same-core/src/provider/traits_tests.rs similarity index 100% rename from src/provider/traits_tests.rs rename to crates/git-same-core/src/provider/traits_tests.rs diff --git a/crates/git-same-core/src/setup/mod.rs b/crates/git-same-core/src/setup/mod.rs new file mode 100644 index 0000000..93b6bf5 --- /dev/null +++ b/crates/git-same-core/src/setup/mod.rs @@ -0,0 +1,141 @@ +//! Headless setup wizard state and services. +//! +//! UI hosts own input mapping and rendering, while this module owns the shared +//! setup state machine plus side-effecting setup services. + +pub mod state; + +use crate::auth::{get_auth_for_provider, gh_cli}; +use crate::checks::CheckResult; +use crate::config::{Config, WorkspaceConfig, WorkspaceManager, WorkspaceProvider}; +use crate::errors::{AppError, Result}; +use crate::provider::create_provider; +pub use state::{ + tilde_collapse, AuthStatus, OrgEntry, PathBrowseEntry, PathSuggestion, ProviderChoice, + SetupOutcome, SetupState, SetupStep, +}; + +/// Authentication result for setup hosts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SetupAuthResult { + /// Token returned by the configured provider auth flow. + pub token: String, + /// Authenticated username when it can be detected. + pub username: Option, +} + +/// Marks the requirements step as loading and records the config path. +pub fn maybe_start_requirements_checks(state: &mut SetupState) -> bool { + if state.step != SetupStep::Requirements || state.checks_triggered { + return false; + } + + state.checks_triggered = true; + state.checks_loading = true; + state.config_path_display = Config::default_path().ok().map(|p| p.display().to_string()); + true +} + +/// Applies completed requirements checks to setup state. +pub fn apply_requirements_check_results(state: &mut SetupState, results: Vec) { + state.check_results = results; + state.checks_loading = false; +} + +/// Runs system requirements checks and applies the results. +pub async fn run_requirements_checks(state: &mut SetupState) { + let results = crate::checks::check_requirements().await; + apply_requirements_check_results(state, results); +} + +/// Authenticates the selected provider and returns token plus username details. +pub async fn authenticate_provider( + ws_provider: WorkspaceProvider, +) -> std::result::Result { + let result = tokio::task::spawn_blocking(move || match get_auth_for_provider(&ws_provider) { + Ok(auth) => { + let username = auth.username.or_else(|| gh_cli::get_username().ok()); + Ok(SetupAuthResult { + token: auth.token, + username, + }) + } + Err(e) => Err(e.to_string()), + }) + .await; + + match result { + Ok(result) => result, + Err(e) => Err(format!("Auth task failed: {}", e)), + } +} + +/// Discovers selectable organization entries for setup. +pub async fn discover_org_entries( + ws_provider: WorkspaceProvider, + token: String, +) -> std::result::Result, String> { + match create_provider(&ws_provider, &token) { + Ok(provider) => match provider.get_organizations().await { + Ok(orgs) => { + let mut org_entries: Vec = Vec::new(); + for org in &orgs { + let repo_count = provider + .get_org_repos(&org.login) + .await + .map(|r| r.len()) + .unwrap_or(0); + org_entries.push(OrgEntry { + name: org.login.clone(), + repo_count, + selected: true, + }); + } + org_entries.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(org_entries) + } + Err(e) => Err(e.to_string()), + }, + Err(e) => Err(e.to_string()), + } +} + +/// Persists the workspace represented by setup state. +pub fn save_workspace(state: &SetupState) -> Result<()> { + let expanded = shellexpand::tilde(&state.base_path); + let root = std::path::Path::new(expanded.as_ref()); + std::fs::create_dir_all(root).map_err(|e| { + AppError::config(format!( + "Failed to create workspace directory '{}': {}", + root.display(), + e + )) + })?; + let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + + let mut ws = WorkspaceConfig::new_from_root(&root); + ws.provider = state.build_workspace_provider(); + ws.username = state.username.clone().unwrap_or_default(); + ws.orgs = state.selected_orgs(); + + WorkspaceManager::save(&ws)?; + + // Paint the Git-Same workspace folder icon onto the root so Finder marks + // it the way Synology Drive marks its synced folders. Failure is logged + // but non-fatal — the workspace is already persisted at this point and + // we don't want a Cocoa-level glitch to abort setup. Opt-out via + // `[ui] custom_folder_icon = false` in the global config. + let ui = Config::load().map(|c| c.ui).unwrap_or_default(); + if ui.custom_folder_icon { + crate::macos::folder_icon::set_or_log( + &root, + crate::macos::folder_icon::WORKSPACE_FOLDER_ICNS, + ); + } + + Ok(()) +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/src/setup/mod_tests.rs b/crates/git-same-core/src/setup/mod_tests.rs similarity index 100% rename from src/setup/mod_tests.rs rename to crates/git-same-core/src/setup/mod_tests.rs diff --git a/src/setup/state.rs b/crates/git-same-core/src/setup/state.rs similarity index 100% rename from src/setup/state.rs rename to crates/git-same-core/src/setup/state.rs diff --git a/src/setup/state_tests.rs b/crates/git-same-core/src/setup/state_tests.rs similarity index 100% rename from src/setup/state_tests.rs rename to crates/git-same-core/src/setup/state_tests.rs diff --git a/crates/git-same-core/src/types/finder_status.rs b/crates/git-same-core/src/types/finder_status.rs new file mode 100644 index 0000000..7b31c5b --- /dev/null +++ b/crates/git-same-core/src/types/finder_status.rs @@ -0,0 +1,290 @@ +//! Types for the Finder extension status data. +//! +//! These types define the JSON schema written by the monitor and read by +//! the FinderSync extension. They represent the complete state needed to +//! render badges, icons, and context menus. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Badge color indicating repository health. +/// +/// Priority order: Red > Orange > Blue > Green. +/// `Gray` is reserved for ambient (non-workspace) repos that haven't been +/// classified yet. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Badge { + /// Everything synced, no local-only data, no important ignored files. + /// Safe to delete. + Green, + /// Fully synced, but has important gitignored files (.env, keys, etc.). + /// Code is on GitHub, but local secrets/config would be lost. + Blue, + /// Main branch clean & synced, but other branches or worktrees diverge. + /// Main branch is safe; other branches or worktrees have local-only data. + Orange, + /// Staged, unstaged, untracked, or unpushed commits. + /// DO NOT delete — uncommitted work or unpushed commits would be lost. + Red, + /// Ambient git repo discovered outside any configured workspace. + /// Upgraded to a semantic color on demand (right-click → REFRESH /path). + Gray, +} + +/// Branch sync status in the context menu. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinderBranchInfo { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub upstream: Option, + pub ahead: u32, + pub behind: u32, + pub synced: bool, +} + +/// Remote info for the context menu. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinderRemoteInfo { + pub name: String, + pub url: String, +} + +/// Worktree info for the context menu. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinderWorktreeInfo { + pub path: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + pub synced: bool, +} + +/// Complete status for a single repository. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinderRepoStatus { + pub path: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub org: Option, + pub badge: Badge, + pub current_branch: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_branch: Option, + pub commit_count: u64, + pub staged_count: usize, + pub unstaged_count: usize, + pub untracked_count: usize, + pub ahead: u32, + pub behind: u32, + pub stash_count: usize, + pub has_important_ignored_files: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub important_ignored_files: Vec, + pub branches: Vec, + pub all_branches_synced: bool, + pub remotes: Vec, + pub worktrees: Vec, + pub all_worktrees_synced: bool, + /// If reading the repo's git state failed, the underlying error message. + /// Set by `scan_repo` when `git status` errors so callers (the CLI + /// `status` command, the FinderSync extension) can distinguish a broken + /// repo from a clean one. The badge for these repos is forced to `Gray`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub read_error: Option, +} + +/// Classification of a GitHub account that owns repositories. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum OwnerType { + /// Personal GitHub account. + User, + /// GitHub Organization account. + Organization, + /// Not yet classified (cache miss) or classification failed. + #[default] + Unknown, +} + +/// An organization or user folder inside a git-same workspace. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OrgFolderInfo { + pub path: PathBuf, + pub org: String, + pub workspace: String, + #[serde(default)] + pub owner_type: OwnerType, +} + +/// Workspace summary for the status file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinderWorkspaceInfo { + pub name: String, + pub root: PathBuf, + pub orgs: Vec, +} + +/// Top-level status file written by the monitor. +/// +/// This is the single source of truth read by the FinderSync extension. +/// Written atomically to the platform-default Finder IPC status path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinderStatus { + pub version: u32, + pub timestamp: String, + pub daemon_pid: u32, + pub workspaces: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub custom_folders: Vec, + pub repos: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub org_folders: Vec, + /// Union of workspace roots and ambient scan roots. The FinderSync + /// extension registers these as `FIFinderSyncController.directoryURLs` + /// so Finder knows which folders to ask about. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub monitored_roots: Vec, + /// `/Volumes/` prefixes whose symlink target is `/` (boot-volume + /// aliases). Finder presents home-folder paths through these aliases + /// (e.g. `/Volumes/Macintosh-HD/Users/m/...`). The sandboxed FinderSync + /// extension uses them as pure-string prefixes to map alias-presented + /// paths back to the canonical keys in `repos`/`org_folders`, so it never + /// has to call `resolvingSymlinksInPath()` and reach outside its sandbox + /// container. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub boot_volume_aliases: Vec, +} + +impl FinderStatus { + /// Current schema version. + pub const VERSION: u32 = 1; + + /// Creates a new empty status. + pub fn new(pid: u32, timestamp: String) -> Self { + Self { + version: Self::VERSION, + timestamp, + daemon_pid: pid, + workspaces: Vec::new(), + custom_folders: Vec::new(), + repos: Vec::new(), + org_folders: Vec::new(), + monitored_roots: Vec::new(), + boot_volume_aliases: Vec::new(), + } + } +} + +/// Default patterns for detecting important gitignored files. +/// +/// These patterns indicate files that contain secrets, credentials, or +/// local configuration that would be lost if the repository were deleted. +pub const DEFAULT_IMPORTANT_IGNORED_PATTERNS: &[&str] = &[ + ".env", + ".env.*", + "*.key", + "*.pem", + "*.p12", + "*.pfx", + "credentials*", + "secrets*", + ".secret*", + "service-account*.json", + "*.keystore", +]; + +/// Compute the badge color for a repository based on its status. +pub fn compute_badge( + staged: usize, + unstaged: usize, + untracked: usize, + ahead: u32, + all_branches_synced: bool, + all_worktrees_synced: bool, + has_important_ignored_files: bool, +) -> Badge { + // Red: any local-only changes or unpushed commits + if staged > 0 || unstaged > 0 || untracked > 0 || ahead > 0 { + return Badge::Red; + } + + // Orange: main branch clean, but other branches/worktrees not synced + if !all_branches_synced || !all_worktrees_synced { + return Badge::Orange; + } + + // Blue: everything synced but has important ignored files + if has_important_ignored_files { + return Badge::Blue; + } + + // Green: fully clean and synced + Badge::Green +} + +/// Check whether a file path matches any of the important ignored patterns. +/// +/// Uses simple glob-like matching: `*` matches any characters, `?` matches one. +pub fn matches_important_pattern(file_path: &str, patterns: &[&str]) -> bool { + let filename = std::path::Path::new(file_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(file_path); + + for pattern in patterns { + if simple_glob_match(pattern, filename) { + return true; + } + } + false +} + +/// Simple glob matching supporting `*` (any chars) and `?` (single char). +fn simple_glob_match(pattern: &str, text: &str) -> bool { + glob_match_recursive( + &pattern.chars().collect::>(), + 0, + &text.chars().collect::>(), + 0, + ) +} + +fn glob_match_recursive(pattern: &[char], pi: usize, text: &[char], ti: usize) -> bool { + if pi == pattern.len() && ti == text.len() { + return true; + } + if pi == pattern.len() { + return false; + } + + match pattern[pi] { + '*' => { + // Try matching * with 0, 1, 2, ... characters + for i in ti..=text.len() { + if glob_match_recursive(pattern, pi + 1, text, i) { + return true; + } + } + false + } + '?' => { + if ti < text.len() { + glob_match_recursive(pattern, pi + 1, text, ti + 1) + } else { + false + } + } + c => { + if ti < text.len() && text[ti] == c { + glob_match_recursive(pattern, pi + 1, text, ti + 1) + } else { + false + } + } + } +} + +#[cfg(test)] +#[path = "finder_status_tests.rs"] +mod tests; diff --git a/crates/git-same-core/src/types/finder_status_tests.rs b/crates/git-same-core/src/types/finder_status_tests.rs new file mode 100644 index 0000000..ac67669 --- /dev/null +++ b/crates/git-same-core/src/types/finder_status_tests.rs @@ -0,0 +1,218 @@ +use super::*; + +#[test] +fn test_compute_badge_green() { + let badge = compute_badge(0, 0, 0, 0, true, true, false); + assert_eq!(badge, Badge::Green); +} + +#[test] +fn test_compute_badge_red_staged() { + let badge = compute_badge(1, 0, 0, 0, true, true, false); + assert_eq!(badge, Badge::Red); +} + +#[test] +fn test_compute_badge_red_unstaged() { + let badge = compute_badge(0, 2, 0, 0, true, true, false); + assert_eq!(badge, Badge::Red); +} + +#[test] +fn test_compute_badge_red_untracked() { + let badge = compute_badge(0, 0, 3, 0, true, true, false); + assert_eq!(badge, Badge::Red); +} + +#[test] +fn test_compute_badge_red_ahead() { + let badge = compute_badge(0, 0, 0, 1, true, true, false); + assert_eq!(badge, Badge::Red); +} + +#[test] +fn test_compute_badge_orange_branches_not_synced() { + let badge = compute_badge(0, 0, 0, 0, false, true, false); + assert_eq!(badge, Badge::Orange); +} + +#[test] +fn test_compute_badge_orange_worktrees_not_synced() { + let badge = compute_badge(0, 0, 0, 0, true, false, false); + assert_eq!(badge, Badge::Orange); +} + +#[test] +fn test_compute_badge_blue_important_ignored() { + let badge = compute_badge(0, 0, 0, 0, true, true, true); + assert_eq!(badge, Badge::Blue); +} + +#[test] +fn test_compute_badge_priority_red_over_orange() { + // Even if branches not synced, staged files = Red + let badge = compute_badge(1, 0, 0, 0, false, false, false); + assert_eq!(badge, Badge::Red); +} + +#[test] +fn test_compute_badge_priority_orange_over_blue() { + // Branches not synced + important ignored = Orange (not Blue) + let badge = compute_badge(0, 0, 0, 0, false, true, true); + assert_eq!(badge, Badge::Orange); +} + +#[test] +fn test_matches_important_pattern_env() { + let patterns = DEFAULT_IMPORTANT_IGNORED_PATTERNS; + assert!(matches_important_pattern(".env", patterns)); + assert!(matches_important_pattern(".env.local", patterns)); + assert!(matches_important_pattern(".env.production", patterns)); + assert!(matches_important_pattern("subdir/.env", patterns)); +} + +#[test] +fn test_matches_important_pattern_keys() { + let patterns = DEFAULT_IMPORTANT_IGNORED_PATTERNS; + assert!(matches_important_pattern("server.key", patterns)); + assert!(matches_important_pattern("cert.pem", patterns)); + assert!(matches_important_pattern("signing.p12", patterns)); +} + +#[test] +fn test_matches_important_pattern_credentials() { + let patterns = DEFAULT_IMPORTANT_IGNORED_PATTERNS; + assert!(matches_important_pattern("credentials.json", patterns)); + assert!(matches_important_pattern("secrets.yaml", patterns)); + assert!(matches_important_pattern( + "service-account-prod.json", + patterns + )); +} + +#[test] +fn test_matches_important_pattern_no_match() { + let patterns = DEFAULT_IMPORTANT_IGNORED_PATTERNS; + assert!(!matches_important_pattern("main.rs", patterns)); + assert!(!matches_important_pattern( + "node_modules/lodash/index.js", + patterns + )); + assert!(!matches_important_pattern( + "target/debug/git-same", + patterns + )); + assert!(!matches_important_pattern("README.md", patterns)); +} + +#[test] +fn test_glob_match_exact() { + assert!(simple_glob_match(".env", ".env")); + assert!(!simple_glob_match(".env", ".envx")); +} + +#[test] +fn test_glob_match_star() { + assert!(simple_glob_match("*.key", "server.key")); + assert!(simple_glob_match("*.key", ".key")); + assert!(!simple_glob_match("*.key", "server.pem")); +} + +#[test] +fn test_glob_match_dot_star() { + assert!(simple_glob_match(".env.*", ".env.local")); + assert!(simple_glob_match(".env.*", ".env.production")); + assert!(!simple_glob_match(".env.*", ".env")); +} + +#[test] +fn test_glob_match_question_mark() { + assert!(simple_glob_match("?.key", "a.key")); + assert!(!simple_glob_match("?.key", "ab.key")); +} + +#[test] +fn test_finder_status_serialization() { + let status = FinderStatus::new(12345, "2026-04-04T10:30:00Z".to_string()); + let json = serde_json::to_string_pretty(&status).unwrap(); + assert!(json.contains("\"version\": 1")); + assert!(json.contains("\"daemon_pid\": 12345")); + + // Round-trip + let parsed: FinderStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, status); +} + +#[test] +fn test_boot_volume_aliases_serialization() { + // Empty: the key is omitted entirely (skip_serializing_if). + let mut status = FinderStatus::new(1, "t".to_string()); + let json = serde_json::to_string(&status).unwrap(); + assert!( + !json.contains("boot_volume_aliases"), + "empty aliases must be omitted, got: {json}" + ); + + // Populated: the key appears and round-trips. + status.boot_volume_aliases = vec!["/Volumes/Macintosh-HD".to_string()]; + let json = serde_json::to_string(&status).unwrap(); + assert!(json.contains("\"boot_volume_aliases\":[\"/Volumes/Macintosh-HD\"]")); + let parsed: FinderStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.boot_volume_aliases, status.boot_volume_aliases); + + // Missing key in input deserializes to an empty vec (serde default), so + // status files written by older monitors stay compatible. + let legacy = r#"{"version":1,"timestamp":"t","daemon_pid":1,"workspaces":[],"repos":[]}"#; + let parsed: FinderStatus = serde_json::from_str(legacy).unwrap(); + assert!(parsed.boot_volume_aliases.is_empty()); +} + +#[test] +fn test_finder_repo_status_serialization() { + let repo = FinderRepoStatus { + path: PathBuf::from("/repos/org/repo"), + workspace: Some("github".to_string()), + org: Some("org".to_string()), + badge: Badge::Green, + current_branch: "main".to_string(), + default_branch: Some("main".to_string()), + commit_count: 847, + staged_count: 0, + unstaged_count: 0, + untracked_count: 0, + ahead: 0, + behind: 0, + stash_count: 0, + has_important_ignored_files: false, + important_ignored_files: Vec::new(), + branches: vec![FinderBranchInfo { + name: "main".to_string(), + upstream: Some("origin/main".to_string()), + ahead: 0, + behind: 0, + synced: true, + }], + all_branches_synced: true, + remotes: vec![FinderRemoteInfo { + name: "origin".to_string(), + url: "git@github.com:org/repo.git".to_string(), + }], + worktrees: Vec::new(), + all_worktrees_synced: true, + read_error: None, + }; + + let json = serde_json::to_string(&repo).unwrap(); + assert!(json.contains("\"badge\":\"green\"")); + + let parsed: FinderRepoStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, repo); +} + +#[test] +fn test_badge_serialization() { + assert_eq!(serde_json::to_string(&Badge::Green).unwrap(), "\"green\""); + assert_eq!(serde_json::to_string(&Badge::Blue).unwrap(), "\"blue\""); + assert_eq!(serde_json::to_string(&Badge::Orange).unwrap(), "\"orange\""); + assert_eq!(serde_json::to_string(&Badge::Red).unwrap(), "\"red\""); +} diff --git a/src/types/mod.rs b/crates/git-same-core/src/types/mod.rs similarity index 71% rename from src/types/mod.rs rename to crates/git-same-core/src/types/mod.rs index 8a235bd..a53260e 100644 --- a/src/types/mod.rs +++ b/crates/git-same-core/src/types/mod.rs @@ -11,8 +11,15 @@ //! - [`OpResult`] - Result of a single operation //! - [`OpSummary`] - Summary statistics for batch operations +pub mod finder_status; mod provider; mod repo; +mod repo_status; +pub use finder_status::{ + Badge, FinderBranchInfo, FinderRemoteInfo, FinderRepoStatus, FinderStatus, FinderWorkspaceInfo, + FinderWorktreeInfo, OrgFolderInfo, OwnerType, +}; pub use provider::ProviderKind; pub use repo::{ActionPlan, OpResult, OpSummary, Org, OwnedRepo, Repo, SkippedRepo}; +pub use repo_status::{RepoEntry, SyncHistoryEntry}; diff --git a/src/types/provider.rs b/crates/git-same-core/src/types/provider.rs similarity index 100% rename from src/types/provider.rs rename to crates/git-same-core/src/types/provider.rs diff --git a/src/types/provider_tests.rs b/crates/git-same-core/src/types/provider_tests.rs similarity index 100% rename from src/types/provider_tests.rs rename to crates/git-same-core/src/types/provider_tests.rs diff --git a/src/types/repo.rs b/crates/git-same-core/src/types/repo.rs similarity index 98% rename from src/types/repo.rs rename to crates/git-same-core/src/types/repo.rs index 573b31e..2ebf14e 100644 --- a/src/types/repo.rs +++ b/crates/git-same-core/src/types/repo.rs @@ -63,7 +63,7 @@ pub struct Repo { impl Repo { /// Creates a minimal repo for testing. - #[cfg(test)] + #[cfg(any(test, feature = "test-utils"))] pub fn test(name: &str, owner: &str) -> Self { Self { id: rand_id(), @@ -86,7 +86,7 @@ impl Repo { } } -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] fn rand_id() -> u64 { use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/crates/git-same-core/src/types/repo_status.rs b/crates/git-same-core/src/types/repo_status.rs new file mode 100644 index 0000000..5dc46b2 --- /dev/null +++ b/crates/git-same-core/src/types/repo_status.rs @@ -0,0 +1,36 @@ +//! Domain types describing a local repository's git state. +//! +//! Lifted out of the TUI module so non-UI callers (`workflows::status_scan`, +//! `cache::sync_history`) can use them without depending on `tui::*`. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// A summary entry for sync history. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncHistoryEntry { + pub timestamp: String, + pub duration_secs: f64, + pub success: usize, + pub failed: usize, + pub skipped: usize, + pub with_updates: usize, + pub cloned: usize, + pub total_new_commits: u32, +} + +/// A local repo with its computed status. +#[derive(Debug, Clone)] +pub struct RepoEntry { + pub owner: String, + pub name: String, + pub full_name: String, + pub path: PathBuf, + pub branch: Option, + pub is_uncommitted: bool, + pub ahead: usize, + pub behind: usize, + pub staged_count: usize, + pub unstaged_count: usize, + pub untracked_count: usize, +} diff --git a/src/types/repo_tests.rs b/crates/git-same-core/src/types/repo_tests.rs similarity index 100% rename from src/types/repo_tests.rs rename to crates/git-same-core/src/types/repo_tests.rs diff --git a/src/workflows/mod.rs b/crates/git-same-core/src/workflows/mod.rs similarity index 74% rename from src/workflows/mod.rs rename to crates/git-same-core/src/workflows/mod.rs index 7f49840..0ca3588 100644 --- a/src/workflows/mod.rs +++ b/crates/git-same-core/src/workflows/mod.rs @@ -1,5 +1,4 @@ //! Use-case workflows. -#[cfg(feature = "tui")] pub mod status_scan; pub mod sync_workspace; diff --git a/src/workflows/status_scan.rs b/crates/git-same-core/src/workflows/status_scan.rs similarity index 95% rename from src/workflows/status_scan.rs rename to crates/git-same-core/src/workflows/status_scan.rs index eec4e46..0e986f1 100644 --- a/src/workflows/status_scan.rs +++ b/crates/git-same-core/src/workflows/status_scan.rs @@ -3,10 +3,9 @@ use crate::config::{Config, WorkspaceConfig}; use crate::discovery::DiscoveryOrchestrator; use crate::git::{GitOperations, ShellGit}; -use crate::tui::app::RepoEntry; +use crate::types::RepoEntry; /// Scan local repositories for git status for a workspace. -#[cfg(feature = "tui")] pub fn scan_workspace_status(config: &Config, workspace: &WorkspaceConfig) -> Vec { let base_path = workspace.expanded_base_path(); if !base_path.exists() { @@ -62,6 +61,6 @@ pub fn scan_workspace_status(config: &Config, workspace: &WorkspaceConfig) -> Ve entries } -#[cfg(all(test, feature = "tui"))] +#[cfg(test)] #[path = "status_scan_tests.rs"] mod tests; diff --git a/src/workflows/status_scan_tests.rs b/crates/git-same-core/src/workflows/status_scan_tests.rs similarity index 100% rename from src/workflows/status_scan_tests.rs rename to crates/git-same-core/src/workflows/status_scan_tests.rs diff --git a/src/workflows/sync_workspace.rs b/crates/git-same-core/src/workflows/sync_workspace.rs similarity index 95% rename from src/workflows/sync_workspace.rs rename to crates/git-same-core/src/workflows/sync_workspace.rs index 53f1a30..ad940f4 100644 --- a/src/workflows/sync_workspace.rs +++ b/crates/git-same-core/src/workflows/sync_workspace.rs @@ -63,8 +63,13 @@ pub async fn prepare_sync_workspace( request: SyncWorkspaceRequest<'_>, discovery_progress: &dyn DiscoveryProgress, ) -> Result { - // Authenticate and build provider - let auth = get_auth_for_provider(&request.workspace.provider)?; + // Authenticate and build provider. The auth path shells out to `gh`, which + // can stall on network or SSH issues; run it on the blocking pool so the + // async runtime stays responsive. + let provider_cfg = request.workspace.provider.clone(); + let auth = tokio::task::spawn_blocking(move || get_auth_for_provider(&provider_cfg)) + .await + .map_err(|e| AppError::auth(format!("Auth task failed: {}", e)))??; let provider = create_provider(&request.workspace.provider, &auth.token)?; // Build orchestrator from workspace + global config diff --git a/src/workflows/sync_workspace_tests.rs b/crates/git-same-core/src/workflows/sync_workspace_tests.rs similarity index 100% rename from src/workflows/sync_workspace_tests.rs rename to crates/git-same-core/src/workflows/sync_workspace_tests.rs diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 60868d4..5ec73b0 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -61,40 +61,58 @@ prefer_ssh = true git clone https://github.com/zaai-com/git-same cd git-same -# Development build -cargo build +# Development build (whole workspace) +cargo build --workspace # Release build (optimized, stripped, with LTO) -cargo build --release +cargo build --release --workspace ``` -The binary is output to `target/release/git-same` (or `target/debug/git-same`). Alias symlinks are created by the install scripts, not by Cargo. +The repository is a Cargo workspace with two member crates: `git-same-core` (engine library, `crates/git-same-core/`) and `git-same` (the CLI binary + TUI, `crates/git-same-cli/` on disk). The release binary is output at the workspace level: `target/release/git-same` (or `target/debug/git-same`). Alias symlinks are created by the install scripts, not by Cargo. + +## Running the macOS App in development + +The Tauri-based desktop app lives at `crates/git-same-app/`. You need [pnpm](https://pnpm.io/) and the [`tauri-cli`](https://v2.tauri.app/reference/cli/) (`cargo install tauri-cli --version "^2.0"`). + +```bash +# Install frontend dependencies +pnpm --dir crates/git-same-app/ui install + +# Start the dev server (Vite + Rust backend with hot reload) +cargo tauri dev --manifest-path crates/git-same-app/Cargo.toml +``` + +The window opens with the workspace dashboard, reading from `~/.config/git-same/config.toml`. The app subscribes to the monitor's `status.json`, so updates from `git-same sync` (run in another terminal) appear live. ## Running tests ```bash -# Run all tests -cargo test +# Run all tests across the workspace +cargo test --workspace # Run with all features enabled -cargo test --all-features +cargo test --workspace --all-features + +# Run tests for a single crate +cargo test -p git-same-core +cargo test -p git-same # Run tests that require GitHub authentication -cargo test -- --ignored +cargo test --workspace -- --ignored # Run with verbose output -cargo test -- --nocapture +cargo test --workspace -- --nocapture ``` ## Test file organization -Unit tests use colocated test files. Each `foo.rs` has a companion `foo_tests.rs` in the same directory, linked via `#[path]` attribute. Integration tests live in `tests/`. +Unit tests use colocated test files. Each `foo.rs` has a companion `foo_tests.rs` in the same directory, linked via `#[path]` attribute. Integration tests live in `crates/git-same-cli/tests/`. ## Linting and formatting ```bash -# Lint -cargo clippy --all-targets --all-features -- -D warnings +# Lint the whole workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings # Check formatting cargo fmt --all -- --check @@ -103,8 +121,8 @@ cargo fmt --all -- --check ## Installing locally ```bash -# Install from source to ~/.cargo/bin/ -cargo install --path . +# Install the CLI from source to ~/.cargo/bin/ +cargo install --path crates/git-same-cli ``` This installs the `git-same` binary. Install via Homebrew to get all aliases automatically. Make sure `~/.cargo/bin` is in your `$PATH`. diff --git a/docs/plans/dependabot-cleanup.md b/docs/plans/dependabot-cleanup.md new file mode 100644 index 0000000..694525b --- /dev/null +++ b/docs/plans/dependabot-cleanup.md @@ -0,0 +1,36 @@ +# Plan — Clear Dependabot alerts (3 low-severity) + +## Context + +Pushing `6ae60ff` surfaced `GitHub found 3 vulnerabilities on ZAAI-com/git-same's default branch (3 low)`. Raw data from `gh api repos/ZAAI-com/git-same/dependabot/alerts`: + +| # | Package | Vulnerable range | Patched | Severity | +|---|---|---|---|---| +| 9 | `rand` | `>= 0.7.0, < 0.9.3` | `0.9.3` | low | +| 10 | `rustls-webpki` | `>= 0.101.0, < 0.103.12` | `0.103.12` | low | +| 11 | `rustls-webpki` | `>= 0.101.0, < 0.103.12` | `0.103.12` | low | + +All three are in `Cargo.lock` only (no source change required). On the `C/Finder-Icons` branch, `Cargo.lock` already shows `rustls-webpki 0.103.12` and `rand 0.9.4` as the primary versions — those alerts may already be resolved on the default branch as of the most recent `Update Cargo.lock` commit (`d576e63`), and GitHub just hasn't re-scanned. However, `Cargo.lock` also still contains a stale `rand 0.8.6` entry (lines 1879-1886) that `cargo tree` cannot trace back to any consumer — a likely cruft entry that a fresh `cargo update` should prune. + +## Steps + +1. On `main` (NOT a Finder branch), run `cargo update` to let Cargo recompute the lockfile. Confirm `rand 0.8.6` drops out. If it doesn't, `cargo tree --target all --all-features -i -p rand@0.8.6` + `grep` Cargo.lock backward to find the consumer — may need a targeted `cargo update -p rand@0.8.6` or a Cargo.toml bump for the intermediate crate. +2. Run `cargo audit` locally (already part of `S1-Test-CI.yml`). Should report zero advisories after step 1. +3. `cargo test && cargo clippy -- -D warnings && cargo fmt -- --check`. +4. Commit as `Bump Cargo.lock to drop vulnerable rand and rustls-webpki versions`. Push to `main` via a small PR (do NOT piggyback on another feature branch — these are independent changes). +5. Wait for Dependabot to re-scan (usually within minutes after push). Confirm the three open alerts auto-close. If any stay open, manually dismiss with a reason via `gh api -X PATCH repos/ZAAI-com/git-same/dependabot/alerts/ -f state=dismissed -f dismissed_reason=fix_started` (only if truly a false positive). + +## Verification + +- `cargo audit` clean locally. +- GitHub Dependabot dashboard shows 0 open alerts. +- Next `S1-Test-CI` workflow run is green on the audit job. + +## Risk / roll-back + +- `cargo update` can pull in minor-bumped transitive deps with behavior changes. After updating, run the full test suite and do a manual smoke test of `gisa sync` against a small real workspace before merging. Roll back by `git restore Cargo.lock`. + +## Out of scope + +- Upgrading to `rustls-webpki 0.104.x` (still alpha per advisory; don't chase pre-releases). +- Adding `cargo deny` or stricter audit gating. diff --git a/docs/plans/refresh-subcommand.md b/docs/plans/refresh-subcommand.md new file mode 100644 index 0000000..be24bb7 --- /dev/null +++ b/docs/plans/refresh-subcommand.md @@ -0,0 +1,56 @@ +# Plan — New `gisa refresh` subcommand + +## Context + +Users occasionally want to force an immediate `status.json` rewrite without running a full sync (e.g. after manually deleting a repo, or when debugging badge issues). The socket protocol already supports this via `REFRESH_ALL` and `REFRESH /path`; we just need a user-facing surface. It's also a natural place to diagnose "is the daemon running?" because the command either succeeds (daemon alive) or prints a helpful error (daemon down, start with `gisa daemon`). + +## Files to create + +- `src/commands/refresh.rs` — new handler, mirroring `src/commands/status.rs:11` shape. +- `src/commands/refresh_tests.rs` — colocated tests per CLAUDE.md convention. + +## Files to modify + +- `src/cli.rs` — add a `Refresh(RefreshArgs)` variant to the `Command` enum (around line 124 where `Status` is defined), plus a `RefreshArgs` struct. Likely flags: + - `--path `: optional single-path refresh (routes to `REFRESH /path` instead of `REFRESH_ALL`). + - No others for the MVP. +- `src/commands/mod.rs:29-68` — add `Command::Refresh(args) => refresh::run(args, &config, output).await` arm in the match block (around line 65 where `Status` is routed). Also declare `pub mod refresh;` near the other modules. +- `run.sh` — append a line to the cheat sheet, consistent with commit `33d0c7a` that added daemon/scan/TUI commands. + +## Handler shape + +```rust +pub async fn run(args: &RefreshArgs, _config: &Config, output: &Output) -> Result<()> { + use crate::ipc::{IpcConfig, UnixSocketClient}; + let cfg = IpcConfig::default_path()?; + let client = UnixSocketClient::new(cfg.socket_path()); + let response = match args.path.as_deref() { + Some(p) => client.refresh(p).await, + None => client.refresh_all().await, + }; + match response { + Ok(_) => { output.success("Daemon refreshed"); Ok(()) } + Err(e) => { output.error("Daemon not reachable. Start it with `gisa daemon`."); Err(e) } + } +} +``` + +Note: unlike the post-sync/post-reset nudges, `gisa refresh` is user-initiated, so a daemon-down state SHOULD return a clear error (not silent). That is the one meaningful behavior difference. + +## Windows / non-unix + +`UnixSocketClient` is `#[cfg(unix)]`. Gate the handler similarly; on non-unix print a short "refresh is unix-only for now" message and return `Ok(())`. `src/ipc/mod.rs:1-8` notes Windows named-pipe support is planned but not shipped. + +## Verification + +1. `gisa daemon` in a terminal. +2. `gisa refresh` → "Daemon refreshed" printed; `status.json` mtime bumps. +3. `gisa refresh --path /path/to/org` → same, targeted. +4. Kill daemon, `gisa refresh` → clear error, non-zero exit. +5. `cargo test` includes new `refresh_tests.rs`. +6. Manual TUI regression pass: no screen references `refresh` yet, so no TUI changes needed. + +## Out of scope + +- Auto-starting the daemon if it's down (needs a separate decision about launchd/systemd integration). +- A `--watch` mode. diff --git a/docs/plans/reset-daemon-nudge.md b/docs/plans/reset-daemon-nudge.md new file mode 100644 index 0000000..687e7cb --- /dev/null +++ b/docs/plans/reset-daemon-nudge.md @@ -0,0 +1,32 @@ +# Plan — Nudge the daemon after `gisa reset` + +## Context + +`gisa reset` removes git-same config, workspace metadata, and cached discovery data (cloned repos stay on disk). Per `src/cli.rs:142-145` help text, this does not delete repos, but it does invalidate everything the daemon currently believes about workspaces. Without a nudge, the daemon keeps serving stale `status.json` until its next poll, so Finder keeps painting "R" badges for workspaces the user just wiped. + +Same root-cause shape as the sync case (commit `6ae60ff`): state changes on disk, daemon doesn't know. + +## Files to modify + +- `src/commands/reset.rs:49` — `pub async fn run(args: &ResetArgs, output: &Output) -> Result<()>`. Add the nudge immediately before the final `Ok(())` at line 74, after `execute_reset()` returns successfully. + +## Reuse + +- Same helper shape already added to `sync_cmd.rs` in commit `6ae60ff` (`nudge_daemon_refresh` private async fn wrapping `UnixSocketClient::refresh_all`). Either duplicate the 12 lines into `reset.rs` or lift it to `src/commands/mod.rs` as a `pub(super)` helper. Duplicating is fine for now — two callers isn't premature abstraction. + +## Gating + +- Fire the nudge only on the real-work path. Reset has no `--dry-run` flag today, only `--force` (skip confirmation). Fire after `execute_reset()` succeeds regardless of `--force`. + +## Verification + +1. Start `gisa daemon` in the background and populate `status.json` via `gisa sync`. +2. Run `gisa reset --force`. +3. In Finder, the previously-badged workspace folders should lose their badges within a second (daemon re-scans and writes an updated `status.json`). +4. Kill the daemon, repeat — reset must still succeed silently. +5. `cargo test && cargo clippy -- -D warnings && cargo fmt -- --check`. + +## Out of scope + +- Any change to what `reset` actually deletes. +- Adding a `--dry-run` flag. diff --git a/macos/GitSameBadges.xcodeproj/project.pbxproj b/macos/GitSameBadges.xcodeproj/project.pbxproj new file mode 100644 index 0000000..dcd708a --- /dev/null +++ b/macos/GitSameBadges.xcodeproj/project.pbxproj @@ -0,0 +1,329 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0C33F590E865457705EBD3FC /* BadgeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8343F7967B3F8D7E6AB7A91 /* BadgeManager.swift */; }; + 5367D36A8F6767ED63560577 /* SocketProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81934E8C1D14054D093A454 /* SocketProtocol.swift */; }; + B3B75C712A824804DCF981B0 /* ContextMenuBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913AFE8244CDF6AE9829DB27 /* ContextMenuBuilder.swift */; }; + DAB8F1E9D9269A7732C09F70 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545B0726C8517036CC45E5F5 /* Constants.swift */; }; + DC172C62A207996085942C13 /* Principal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15307DCF3AB10B0512CD020A /* Principal.swift */; }; + EB3305CF18666124EB44A7EE /* StatusModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8F66E45A3050726370F5B8 /* StatusModels.swift */; }; + FFC45929858BE49E81222E0E /* StatusReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F95DF884CB07855C1D7C66 /* StatusReader.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 15307DCF3AB10B0512CD020A /* Principal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Principal.swift; sourceTree = ""; }; + 545B0726C8517036CC45E5F5 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 61AF581BDDD5DCB1387D64E4 /* GitSameBadges.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GitSameBadges.entitlements; sourceTree = ""; }; + 913AFE8244CDF6AE9829DB27 /* ContextMenuBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuBuilder.swift; sourceTree = ""; }; + 9D8F66E45A3050726370F5B8 /* StatusModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusModels.swift; sourceTree = ""; }; + AB229AA7479834C325EB99A8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + AE88C9527D7D39CD8F7A3C63 /* GitSameBadges.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = GitSameBadges.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + B8343F7967B3F8D7E6AB7A91 /* BadgeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeManager.swift; sourceTree = ""; }; + C6F95DF884CB07855C1D7C66 /* StatusReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusReader.swift; sourceTree = ""; }; + E81934E8C1D14054D093A454 /* SocketProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketProtocol.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 43651E8F046591C71351AE27 = { + isa = PBXGroup; + children = ( + 75232AD31082397A565782DE /* GitSameBadges */, + 6CFF0D192987E49E1BA2859F /* Shared */, + 44E4ECBCE05E2A7A5F2EE2CD /* Products */, + ); + sourceTree = ""; + }; + 44E4ECBCE05E2A7A5F2EE2CD /* Products */ = { + isa = PBXGroup; + children = ( + AE88C9527D7D39CD8F7A3C63 /* GitSameBadges.appex */, + ); + name = Products; + sourceTree = ""; + }; + 6CFF0D192987E49E1BA2859F /* Shared */ = { + isa = PBXGroup; + children = ( + 545B0726C8517036CC45E5F5 /* Constants.swift */, + E81934E8C1D14054D093A454 /* SocketProtocol.swift */, + 9D8F66E45A3050726370F5B8 /* StatusModels.swift */, + C6F95DF884CB07855C1D7C66 /* StatusReader.swift */, + ); + path = Shared; + sourceTree = ""; + }; + 75232AD31082397A565782DE /* GitSameBadges */ = { + isa = PBXGroup; + children = ( + B8343F7967B3F8D7E6AB7A91 /* BadgeManager.swift */, + 913AFE8244CDF6AE9829DB27 /* ContextMenuBuilder.swift */, + 15307DCF3AB10B0512CD020A /* Principal.swift */, + 61AF581BDDD5DCB1387D64E4 /* GitSameBadges.entitlements */, + AB229AA7479834C325EB99A8 /* Info.plist */, + ); + path = GitSameBadges; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 0DD1148AB87797C1EFE47734 /* GitSameBadges */ = { + isa = PBXNativeTarget; + buildConfigurationList = 442FF77D5CAC09A6A71739F3 /* Build configuration list for PBXNativeTarget "GitSameBadges" */; + buildPhases = ( + D43CBF7D710B9FD3DCC5713E /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = GitSameBadges; + packageProductDependencies = ( + ); + productName = GitSameBadges; + productReference = AE88C9527D7D39CD8F7A3C63 /* GitSameBadges.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 76273B1C39F5CA108DFFF958 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1500; + TargetAttributes = { + 0DD1148AB87797C1EFE47734 = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = C800113306606019226F625B /* Build configuration list for PBXProject "GitSameBadges" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 43651E8F046591C71351AE27; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 44E4ECBCE05E2A7A5F2EE2CD /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 0DD1148AB87797C1EFE47734 /* GitSameBadges */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + D43CBF7D710B9FD3DCC5713E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C33F590E865457705EBD3FC /* BadgeManager.swift in Sources */, + DAB8F1E9D9269A7732C09F70 /* Constants.swift in Sources */, + B3B75C712A824804DCF981B0 /* ContextMenuBuilder.swift in Sources */, + DC172C62A207996085942C13 /* Principal.swift in Sources */, + 5367D36A8F6767ED63560577 /* SocketProtocol.swift in Sources */, + EB3305CF18666124EB44A7EE /* StatusModels.swift in Sources */, + FFC45929858BE49E81222E0E /* StatusReader.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 4D4BC397944FC7FCA785061B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.9; + }; + name = Debug; + }; + 4D7367DC577A43874571BD32 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = GitSameBadges/GitSameBadges.entitlements; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 57KL6Y7V32; + INFOPLIST_FILE = GitSameBadges/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.zaai.git-same.badges"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Debug; + }; + 512EA8428CC0E29C2F91C2C9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = GitSameBadges/GitSameBadges.entitlements; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 57KL6Y7V32; + INFOPLIST_FILE = GitSameBadges/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.zaai.git-same.badges"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Release; + }; + C103BD2520D00F5F82A14303 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.9; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 442FF77D5CAC09A6A71739F3 /* Build configuration list for PBXNativeTarget "GitSameBadges" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4D7367DC577A43874571BD32 /* Debug */, + 512EA8428CC0E29C2F91C2C9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + C800113306606019226F625B /* Build configuration list for PBXProject "GitSameBadges" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4D4BC397944FC7FCA785061B /* Debug */, + C103BD2520D00F5F82A14303 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 76273B1C39F5CA108DFFF958 /* Project object */; +} diff --git a/macos/GitSameBadges.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/macos/GitSameBadges.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/macos/GitSameBadges.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/GitSameBadges.xcodeproj/xcshareddata/xcschemes/GitSameBadges.xcscheme b/macos/GitSameBadges.xcodeproj/xcshareddata/xcschemes/GitSameBadges.xcscheme new file mode 100644 index 0000000..6fa5366 --- /dev/null +++ b/macos/GitSameBadges.xcodeproj/xcshareddata/xcschemes/GitSameBadges.xcscheme @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + diff --git a/macos/GitSameBadges/BadgeManager.swift b/macos/GitSameBadges/BadgeManager.swift new file mode 100644 index 0000000..ccd65f1 --- /dev/null +++ b/macos/GitSameBadges/BadgeManager.swift @@ -0,0 +1,73 @@ +// BadgeManager.swift +// Registers badge images with the FinderSync controller. + +import Cocoa +import FinderSync + +enum BadgeManager { + /// Register all badge images with FinderSync. + /// Called once during extension initialization. + static func registerBadges() { + let controller = FIFinderSyncController.default() + + controller.setBadgeImage( + symbolBadge(symbol: "r.square.fill", color: .systemGreen), + label: "Synced", + forBadgeIdentifier: GitSameBadgesConstants.BadgeID.green + ) + controller.setBadgeImage( + symbolBadge(symbol: "r.square.fill", color: .systemBlue), + label: "Has Local Config", + forBadgeIdentifier: GitSameBadgesConstants.BadgeID.blue + ) + controller.setBadgeImage( + symbolBadge(symbol: "r.square.fill", color: .systemOrange), + label: "Partially Synced", + forBadgeIdentifier: GitSameBadgesConstants.BadgeID.orange + ) + controller.setBadgeImage( + symbolBadge(symbol: "r.square.fill", color: .systemRed), + label: "Uncommitted Changes", + forBadgeIdentifier: GitSameBadgesConstants.BadgeID.red + ) + controller.setBadgeImage( + symbolBadge(symbol: "r.square.fill", color: .systemGray), + label: "Git Repository", + forBadgeIdentifier: GitSameBadgesConstants.BadgeID.gray + ) + controller.setBadgeImage( + symbolBadge(symbol: "o.square.fill", color: .systemBlue), + label: "Organization", + forBadgeIdentifier: GitSameBadgesConstants.BadgeID.org + ) + controller.setBadgeImage( + symbolBadge(symbol: "u.square.fill", color: .systemBlue), + label: "User", + forBadgeIdentifier: GitSameBadgesConstants.BadgeID.user + ) + } + + /// Two-color SF Symbol badge: white letter on a colored rounded square. + /// + /// SF Symbols are system-rendered images that don't depend on any + /// per-process drawing context. They sidestep the macOS 26.4 + /// FinderSync regression where both `lockFocus` and + /// `NSImage(size:flipped:drawingHandler:)` produce blank pixel data + /// inside the extension sandbox. + /// + /// `r.square.fill` / `o.square.fill` / `u.square.fill` are layered SF + /// Symbols: layer 0 is the letter glyph, layer 1 is the filled square. + /// Passing two palette colors forces white on the letter and the badge + /// color on the square, restoring the visible R/O/U identity that the + /// original lockFocus-drawn badges had. The square color is darkened + /// 20% so the letter contrast holds at small icon sizes. + private static func symbolBadge(symbol: String, color: NSColor) -> NSImage { + let darker = color.shadow(withLevel: 0.2) ?? color + let config = NSImage.SymbolConfiguration(pointSize: 256, weight: .heavy) + .applying(NSImage.SymbolConfiguration(paletteColors: [.white, darker])) + let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)? + .withSymbolConfiguration(config) + return image ?? NSImage(size: NSSize(width: 16, height: 16)) + } + +} diff --git a/macos/GitSameBadges/ContextMenuBuilder.swift b/macos/GitSameBadges/ContextMenuBuilder.swift new file mode 100644 index 0000000..b45ebe5 --- /dev/null +++ b/macos/GitSameBadges/ContextMenuBuilder.swift @@ -0,0 +1,583 @@ +// ContextMenuBuilder.swift +// Builds the right-click context menu for git repository folders and org folders. +// Everything lives under a single top-level "Git-Same" item. Inside, data is +// grouped into four sub-submenus: Organization, Workspace, Repositories / +// Repository, Repository list / Repository details — followed by the +// last-scan timestamp and the action items. + +import Cocoa + +enum ContextMenuBuilder { + /// Build the context menu for a repository folder. + static func build(for repo: FinderRepoStatus, + workspaceInfo: FinderWorkspaceInfo?, + timestamp: String?, + socketClient: SocketClient) -> NSMenu { + // Ambient repos ship with `.gray` and no git details. Fire a targeted + // REFRESH so the monitor runs a full scan_repo on this path; the + // StatusReader file watcher will then replace the gray badge with a + // semantic color within the next Finder tick. + if repo.badge == .gray { + socketClient.send("REFRESH \(repo.path)") { _ in } + } + let menu = NSMenu(title: "Git-Same") + menu.addItem(parentItem(badge: repo.badge, + submenu: repoRoot(repo: repo, + workspaceInfo: workspaceInfo, + timestamp: timestamp))) + return menu + } + + /// Build the context menu for an organization (or user) folder. + static func build(for org: OrgFolderInfo, + repos: [FinderRepoStatus], + workspaceInfo: FinderWorkspaceInfo?, + timestamp: String?) -> NSMenu { + let menu = NSMenu(title: "Git-Same") + menu.addItem(parentItem(badge: nil, + submenu: orgRoot(org: org, repos: repos, + workspaceInfo: workspaceInfo, + timestamp: timestamp))) + return menu + } + + // MARK: - Parent item + + private static func parentItem(badge: Badge?, submenu: NSMenu) -> NSMenuItem { + let prefix = badge.map(badgeEmoji) ?? "\u{1F7E3}" + let item = NSMenuItem(title: "\(prefix) Git-Same", action: nil, keyEquivalent: "") + item.submenu = submenu + return item + } + + // MARK: - Repo root submenu + + private static func repoRoot(repo: FinderRepoStatus, + workspaceInfo: FinderWorkspaceInfo?, + timestamp: String?) -> NSMenu { + let root = NSMenu() + + root.addItem(submenuRow(title: "Organization", + content: repoOrganizationSubmenu(repo: repo))) + root.addItem(submenuRow(title: "Workspace", + content: repoWorkspaceSubmenu(workspaceInfo: workspaceInfo))) + root.addItem(submenuRow(title: "Repository", + content: repoSelfSubmenu(repo: repo))) + root.addItem(submenuRow(title: "Repository details", + content: repoDetailsSubmenu(repo: repo))) + + if let stamp = formatTimestamp(timestamp) { + root.addItem(NSMenuItem.separator()) + root.addItem(infoItem("Last scan: \(stamp)")) + } + + root.addItem(NSMenuItem.separator()) + root.addItem(NSMenuItem( + title: "\u{21BB} Refresh Status", + action: #selector(Principal.refreshStatus(_:)), + keyEquivalent: "" + )) + root.addItem(NSMenuItem( + title: "Open in Terminal", + action: #selector(Principal.openInTerminal(_:)), + keyEquivalent: "" + )) + return root + } + + private static func repoOrganizationSubmenu(repo: FinderRepoStatus) -> NSMenu { + let sub = NSMenu() + if let org = repo.org { + sub.addItem(infoItem("Org: \(org)")) + } else { + sub.addItem(infoItem("(no org)")) + } + return sub + } + + private static func repoWorkspaceSubmenu(workspaceInfo: FinderWorkspaceInfo?) -> NSMenu { + let sub = NSMenu() + if let ws = workspaceInfo { + sub.addItem(infoItem("Name: \(ws.name)")) + sub.addItem(infoItem("Root: \(ws.root)")) + } else { + sub.addItem(infoItem("(no workspace)")) + } + return sub + } + + private static func repoSelfSubmenu(repo: FinderRepoStatus) -> NSMenu { + let sub = NSMenu() + + sub.addItem(statusRow(badge: repo.badge)) + + if repo.badge == .gray { + sub.addItem(ambientHintItem()) + sub.addItem(NSMenuItem.separator()) + sub.addItem(infoItem("Path: \(repo.path)")) + return sub + } + + sub.addItem(NSMenuItem.separator()) + + sub.addItem(infoItem("Branch: \(repo.currentBranch)")) + sub.addItem(infoItem(upstreamLine(for: repo))) + if let defaultBranch = repo.defaultBranch, defaultBranch != repo.currentBranch { + sub.addItem(infoItem("Default: \(defaultBranch)")) + } + sub.addItem(infoItem(indexLine(for: repo))) + sub.addItem(infoItem(workdirLine(for: repo))) + if repo.stashCount > 0 { + sub.addItem(infoItem("Stashes: \(repo.stashCount)")) + } + if repo.hasImportantIgnoredFiles { + sub.addItem(importantIgnoredItem(repo: repo)) + } + + sub.addItem(NSMenuItem.separator()) + + sub.addItem(infoItem("Commits: \(formattedCommits(repo.commitCount))")) + sub.addItem(infoItem("Path: \(repo.path)")) + + return sub + } + + private static func repoDetailsSubmenu(repo: FinderRepoStatus) -> NSMenu { + let sub = NSMenu() + if !repo.branches.isEmpty { + let label = repo.allBranchesSynced + ? "Branches \(repo.branches.count) (\u{2713} all synced)" + : "Branches \(repo.branches.count) (some out of sync)" + sub.addItem(branchesItem(title: label, branches: repo.branches, + currentBranch: repo.currentBranch)) + } + if !repo.remotes.isEmpty { + sub.addItem(remotesItem(remotes: repo.remotes)) + } + if !repo.worktrees.isEmpty { + let label = repo.allWorktreesSynced + ? "Worktrees \(repo.worktrees.count) (\u{2713} all synced)" + : "Worktrees \(repo.worktrees.count) (some out of sync)" + sub.addItem(worktreesItem(title: label, worktrees: repo.worktrees)) + } + if sub.items.isEmpty { + sub.addItem(infoItem("(no branches, remotes, or worktrees)")) + } + return sub + } + + // MARK: - Org root submenu + + private static func orgRoot(org: OrgFolderInfo, + repos: [FinderRepoStatus], + workspaceInfo: FinderWorkspaceInfo?, + timestamp: String?) -> NSMenu { + let root = NSMenu() + + root.addItem(submenuRow(title: "Organization", + content: orgOrganizationSubmenu(org: org))) + root.addItem(submenuRow(title: "Workspace", + content: workspaceSubmenu(workspaceInfo: workspaceInfo, + currentOrg: org.org))) + root.addItem(submenuRow(title: "Repositories (\(repos.count))", + content: orgAggregateSubmenu(repos: repos))) + root.addItem(submenuRow(title: "Repository list", + content: orgRepoListSubmenu(repos: repos))) + + if let stamp = formatTimestamp(timestamp) { + root.addItem(NSMenuItem.separator()) + root.addItem(infoItem("Last scan: \(stamp)")) + } + + root.addItem(NSMenuItem.separator()) + root.addItem(NSMenuItem( + title: "\u{21BB} Refresh Status", + action: #selector(Principal.refreshStatus(_:)), + keyEquivalent: "" + )) + return root + } + + private static func orgOrganizationSubmenu(org: OrgFolderInfo) -> NSMenu { + let sub = NSMenu() + sub.addItem(infoItem("Owner: \(org.org)")) + if let typeLabel = ownerTypeLabel(org.ownerType) { + sub.addItem(infoItem("Type: \(typeLabel)")) + } + sub.addItem(infoItem("Path: \(org.path)")) + return sub + } + + private static func workspaceSubmenu(workspaceInfo: FinderWorkspaceInfo?, + currentOrg: String?) -> NSMenu { + let sub = NSMenu() + guard let ws = workspaceInfo else { + sub.addItem(infoItem("(no workspace)")) + return sub + } + sub.addItem(infoItem("Name: \(ws.name)")) + sub.addItem(infoItem("Root: \(ws.root)")) + sub.addItem(infoItem("Orgs in workspace: \(ws.orgs.count)")) + if !ws.orgs.isEmpty { + let orgsItem = NSMenuItem(title: "All orgs", action: nil, keyEquivalent: "") + let orgsSubmenu = NSMenu() + for name in ws.orgs.sorted() { + let marker = (name == currentOrg) ? "\u{2713} " : " " + orgsSubmenu.addItem(infoItem("\(marker)\(name)")) + } + orgsItem.submenu = orgsSubmenu + sub.addItem(orgsItem) + } + return sub + } + + private static func orgAggregateSubmenu(repos: [FinderRepoStatus]) -> NSMenu { + let sub = NSMenu() + if repos.isEmpty { + sub.addItem(infoItem("(no repositories)")) + return sub + } + + let counts = badgeCounts(for: repos) + sub.addItem(infoItem("Repos: \(repos.count)")) + sub.addItem(infoItem( + "\u{1F7E2} \(counts.green) | \u{1F535} \(counts.blue) | " + + "\u{1F7E0} \(counts.orange) | \u{1F534} \(counts.red)" + + " | \u{26AB} \(counts.gray)" + )) + + let totals = aggregate(repos: repos) + sub.addItem(infoItem("Total commits: \(formattedCommits(totals.commits))")) + if totals.staged > 0 || totals.unstaged > 0 || totals.untracked > 0 { + sub.addItem(infoItem( + "Uncommitted \u{2014} staged: \(totals.staged), " + + "unstaged: \(totals.unstaged), untracked: \(totals.untracked)" + )) + } + if totals.ahead > 0 || totals.behind > 0 { + sub.addItem(infoItem( + "Total ahead: \(totals.ahead) | behind: \(totals.behind)" + )) + } + if totals.stashes > 0 { + sub.addItem(infoItem("Total stashes: \(totals.stashes)")) + } + + let secretRepos = repos.filter { $0.hasImportantIgnoredFiles } + if !secretRepos.isEmpty { + let warnTitle = "\u{26A0} Repos with sensitive files (\(secretRepos.count))" + let warnItem = NSMenuItem(title: warnTitle, action: nil, keyEquivalent: "") + let warnSubmenu = NSMenu() + for r in secretRepos.sorted(by: { repoBasename($0) < repoBasename($1) }) { + warnSubmenu.addItem(infoItem(repoBasename(r))) + } + warnItem.submenu = warnSubmenu + sub.addItem(warnItem) + } + return sub + } + + private static func orgRepoListSubmenu(repos: [FinderRepoStatus]) -> NSMenu { + let sub = NSMenu() + if repos.isEmpty { + sub.addItem(infoItem("(no repositories)")) + return sub + } + for r in repos.sorted(by: { repoBasename($0) < repoBasename($1) }) { + let line = "\(badgeEmoji(r.badge)) \(repoBasename(r)) [\(r.currentBranch)]" + let row = NSMenuItem(title: line, action: nil, keyEquivalent: "") + row.submenu = repoSelfSubmenu(repo: r) + sub.addItem(row) + } + return sub + } + + // MARK: - Repository submenu helpers + + private static func statusRow(badge: Badge) -> NSMenuItem { + let title = "\(badgeEmoji(badge)) \(badgeMeaning(badge))" + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: NSFont.systemFontSize, weight: .semibold) + ] + item.attributedTitle = NSAttributedString(string: title, attributes: attrs) + return item + } + + private static func ambientHintItem() -> NSMenuItem { + let title = "Refreshing in background\u{2026}" + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + let base = NSFont.systemFont(ofSize: NSFont.systemFontSize) + let italic = NSFont(descriptor: base.fontDescriptor.withSymbolicTraits(.italic), + size: NSFont.systemFontSize) ?? base + let attrs: [NSAttributedString.Key: Any] = [ + .font: italic, + .foregroundColor: NSColor.secondaryLabelColor + ] + item.attributedTitle = NSAttributedString(string: title, attributes: attrs) + return item + } + + private static func upstreamLine(for repo: FinderRepoStatus) -> String { + let currentInfo = repo.branches.first { $0.name == repo.currentBranch } + guard let upstream = currentInfo?.upstream else { + return "Upstream: none" + } + let state: String + if repo.ahead > 0 && repo.behind > 0 { + state = "\(repo.ahead) ahead, \(repo.behind) behind" + } else if repo.ahead > 0 { + state = "\(repo.ahead) ahead" + } else if repo.behind > 0 { + state = "\(repo.behind) behind" + } else { + state = "synced" + } + return "Upstream: \(upstream) (\(state))" + } + + private static func indexLine(for repo: FinderRepoStatus) -> String { + if repo.stagedCount == 0 { + return "Index: clean" + } + return "Index: \(repo.stagedCount) staged" + } + + private static func workdirLine(for repo: FinderRepoStatus) -> String { + if repo.unstagedCount == 0 && repo.untrackedCount == 0 { + return "Workdir: clean" + } + var parts: [String] = [] + if repo.unstagedCount > 0 { parts.append("\(repo.unstagedCount) unstaged") } + if repo.untrackedCount > 0 { parts.append("\(repo.untrackedCount) untracked") } + return "Workdir: " + parts.joined(separator: " \u{00B7} ") + } + + private static func importantIgnoredItem(repo: FinderRepoStatus) -> NSMenuItem { + let patterns = repo.importantIgnoredFiles ?? [] + let title = "\u{26A0} Important ignored files (\(patterns.count))" + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + + let attributed = NSMutableAttributedString(string: title) + let warnRange = (title as NSString).range(of: "\u{26A0}") + if warnRange.location != NSNotFound { + attributed.addAttribute(.foregroundColor, + value: NSColor.systemYellow, + range: warnRange) + } + item.attributedTitle = attributed + + if !patterns.isEmpty { + let sub = NSMenu() + for p in patterns { + sub.addItem(infoItem(p)) + } + item.submenu = sub + } else { + item.isEnabled = false + } + return item + } + + // MARK: - Branches / Remotes / Worktrees rows + + private static func branchesItem(title: String, branches: [FinderBranchInfo], + currentBranch: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + let sub = NSMenu() + for branch in branches { + let checkmark = (branch.name == currentBranch) ? "\u{2713} " : " " + let syncStatus: String + if branch.synced { + syncStatus = "(synced)" + } else if branch.ahead > 0 && branch.behind > 0 { + syncStatus = "(ahead \(branch.ahead), behind \(branch.behind))" + } else if branch.ahead > 0 { + syncStatus = "(ahead \(branch.ahead))" + } else if branch.behind > 0 { + syncStatus = "(behind \(branch.behind))" + } else if branch.upstream == nil { + syncStatus = "(no upstream)" + } else { + syncStatus = "" + } + let row = NSMenuItem(title: "\(checkmark)\(branch.name) \(syncStatus)", + action: nil, keyEquivalent: "") + if let upstream = branch.upstream { + let detail = NSMenu() + detail.addItem(infoItem("Upstream: \(upstream)")) + detail.addItem(infoItem("Ahead: \(branch.ahead) | Behind: \(branch.behind)")) + detail.addItem(infoItem(branch.synced ? "Synced" : "Out of sync")) + row.submenu = detail + } else { + row.isEnabled = false + } + sub.addItem(row) + } + item.submenu = sub + return item + } + + private static func remotesItem(remotes: [FinderRemoteInfo]) -> NSMenuItem { + let item = NSMenuItem(title: "Remotes (\(remotes.count))", + action: nil, keyEquivalent: "") + let sub = NSMenu() + for remote in remotes { + sub.addItem(infoItem("\(remote.name): \(remote.url)")) + } + item.submenu = sub + return item + } + + private static func worktreesItem(title: String, + worktrees: [FinderWorktreeInfo]) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + let sub = NSMenu() + for wt in worktrees { + let syncMark = wt.synced ? "\u{2713}" : "\u{2717}" + let branch = wt.branch ?? "detached" + sub.addItem(infoItem("\(wt.path) (\(branch)) \(syncMark)")) + } + item.submenu = sub + return item + } + + // MARK: - Helpers + + private static func submenuRow(title: String, content: NSMenu) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.submenu = content + return item + } + + private static func badgeEmoji(_ badge: Badge) -> String { + switch badge { + case .green: return "\u{1F7E2}" // green circle + case .blue: return "\u{1F535}" // blue circle + case .orange: return "\u{1F7E0}" // orange circle + case .red: return "\u{1F534}" // red circle + case .gray: return "\u{26AB}" // black/gray circle + } + } + + private static func badgeMeaning(_ badge: Badge) -> String { + switch badge { + case .green: return "Synced" + case .blue: return "Has Local Config" + case .orange: return "Partially Synced" + case .red: return "Uncommitted Changes" + case .gray: return "Git Repository" + } + } + + private static func ownerTypeLabel(_ ownerType: OwnerType?) -> String? { + switch ownerType { + case .some(.user): return "User" + case .some(.organization): return "Organization" + case .some(.unknown), .none: return nil + } + } + + private struct BadgeCounts { + var green = 0 + var blue = 0 + var orange = 0 + var red = 0 + var gray = 0 + } + + private static func badgeCounts(for repos: [FinderRepoStatus]) -> BadgeCounts { + var counts = BadgeCounts() + for r in repos { + switch r.badge { + case .green: counts.green += 1 + case .blue: counts.blue += 1 + case .orange: counts.orange += 1 + case .red: counts.red += 1 + case .gray: counts.gray += 1 + } + } + return counts + } + + private struct AggregateTotals { + var commits: UInt64 = 0 + var staged: Int = 0 + var unstaged: Int = 0 + var untracked: Int = 0 + var ahead: UInt32 = 0 + var behind: UInt32 = 0 + var stashes: Int = 0 + } + + private static func aggregate(repos: [FinderRepoStatus]) -> AggregateTotals { + var t = AggregateTotals() + for r in repos { + t.commits += r.commitCount + t.staged += r.stagedCount + t.unstaged += r.unstagedCount + t.untracked += r.untrackedCount + t.ahead += r.ahead + t.behind += r.behind + t.stashes += r.stashCount + } + return t + } + + private static func repoBasename(_ repo: FinderRepoStatus) -> String { + return (repo.path as NSString).lastPathComponent + } + + private static let commitsFormatter: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + return f + }() + + private static func formattedCommits(_ count: UInt64) -> String { + return commitsFormatter.string(from: NSNumber(value: count)) ?? "\(count)" + } + + private static let isoFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + private static let isoFormatterNoFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .full + return f + }() + + private static let absoluteFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .short + f.timeStyle = .medium + return f + }() + + private static func formatTimestamp(_ stamp: String?) -> String? { + guard let stamp = stamp, !stamp.isEmpty else { return nil } + let date = isoFormatter.date(from: stamp) + ?? isoFormatterNoFractional.date(from: stamp) + guard let date = date else { return stamp } + let relative = relativeFormatter.localizedString(for: date, relativeTo: Date()) + let absolute = absoluteFormatter.string(from: date) + return "\(relative) (\(absolute))" + } + + private static func infoItem(_ title: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + return item + } +} diff --git a/macos/GitSameBadges/GitSameBadges.entitlements b/macos/GitSameBadges/GitSameBadges.entitlements new file mode 100644 index 0000000..b1d1b3d --- /dev/null +++ b/macos/GitSameBadges/GitSameBadges.entitlements @@ -0,0 +1,33 @@ + + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.57KL6Y7V32.com.zaai.git-same + + + diff --git a/macos/GitSameBadges/Info.plist b/macos/GitSameBadges/Info.plist new file mode 100644 index 0000000..35a6947 --- /dev/null +++ b/macos/GitSameBadges/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Git-Same-Badges + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + com.zaai.git-same.badges + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + GitSameBadges + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.FinderSync + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).Principal + + + diff --git a/macos/GitSameBadges/Principal.swift b/macos/GitSameBadges/Principal.swift new file mode 100644 index 0000000..983bcca --- /dev/null +++ b/macos/GitSameBadges/Principal.swift @@ -0,0 +1,267 @@ +// Principal.swift +// macOS FinderSync extension principal class. Displays Git-Same status +// badges and right-click context menus on workspace and ambient repos. + +import Cocoa +import FinderSync +import os + +private let gsbLog = OSLog(subsystem: "com.zaai.git-same.badges", category: "ext") + +class Principal: FIFinderSync { + + let statusReader = StatusReader.shared + let socketClient = SocketClient() + + private var lastRefreshRequest: [String: Date] = [:] + private static let refreshThrottle: TimeInterval = 10.0 + + override init() { + super.init() + os_log("Principal init entered", log: gsbLog, type: .default) + + BadgeManager.registerBadges() + + statusReader.onStatusUpdate = { [weak self] in + self?.updateMonitoredDirectories() + self?.prefillBadges() + } + statusReader.startWatching() + + // StatusReader reads the file eagerly in its init but only invokes + // onStatusUpdate on subsequent file-change events. Without this call, + // directoryURLs stays empty until the monitor next rewrites the file, + // so Finder never asks us for badges. + updateMonitoredDirectories() + prefillBadges() + } + + // MARK: - Monitored Directories + + private func updateMonitoredDirectories() { + guard let status = statusReader.currentStatus else { + os_log("updateMonitoredDirectories: no status yet", log: gsbLog, type: .default) + return + } + + var canonicalRoots: [String] = [] + // Prefer the monitor-provided monitored_roots (workspace roots ∪ ambient + // scan roots). Fall back to the workspace+custom_folders union for + // older monitors that predate that field. + if let roots = status.monitoredRoots, !roots.isEmpty { + canonicalRoots = roots + } else { + for workspace in status.workspaces { + canonicalRoots.append(workspace.root) + } + for folder in status.customFolders ?? [] { + canonicalRoots.append(folder) + } + } + + // macOS lets users browse their home folder via a boot-volume alias + // (e.g. /Volumes/Manuel-SSD-4TB/Users/m/... -> /Users/m/...). Finder + // calls beginObservingDirectoryAtURL: with the alias-prefixed URL, + // so directoryURLs needs the alias-prefixed form too — otherwise + // requestBadgeIdentifier is never invoked for those windows. The + // monitor (non-sandboxed) computes these prefixes and ships them in + // status.json, so this sandboxed extension never enumerates /Volumes + // itself and never touches the disk outside its container. + let aliasPrefixes = status.bootVolumeAliases ?? [] + var urls = Set() + for root in canonicalRoots { + urls.insert(URL(fileURLWithPath: root)) + for prefix in aliasPrefixes { + urls.insert(URL(fileURLWithPath: prefix + root)) + } + } + + FIFinderSyncController.default().directoryURLs = urls + let joined = urls.map { $0.path }.joined(separator: ",") + os_log("setDirectoryURLs count=%d paths=%{public}@", + log: gsbLog, type: .default, urls.count, joined) + + // Seed Finder's badge cache now that directoryURLs includes these + // roots — any URL in status.json under them will get its real badge + // before Finder's first paint request. + prefillBadges() + } + + /// Map an alias-presented path back to its canonical form using pure + /// string operations — no filesystem access, so this sandboxed extension + /// never reaches outside its container (which is what triggered the + /// "access data from other apps" TCC prompt). For each boot-volume-alias + /// prefix `/Volumes/` (whose target is `/`), a path like + /// `/Volumes//Users/m/x` becomes `/Users/m/x`. Paths that carry no + /// known alias prefix are returned unchanged. + private func canonicalFromAlias(_ path: String) -> String { + for prefix in statusReader.currentStatus?.bootVolumeAliases ?? [] { + if path == prefix { + return "/" + } + if path.hasPrefix(prefix + "/") { + return String(path.dropFirst(prefix.count)) + } + } + return path + } + + // MARK: - Badge Identifiers + + override func requestBadgeIdentifier(for url: URL) { + let path = url.path + let resolved = canonicalFromAlias(path) + let controller = FIFinderSyncController.default() + + if let orgFolder = orgFolderLookup(path: path, resolved: resolved) { + let finalID = orgFolder.ownerType == .user + ? GitSameBadgesConstants.BadgeID.user + : GitSameBadgesConstants.BadgeID.org + controller.setBadgeIdentifier(finalID, for: url) + return + } + + if let repoStatus = repoLookup(path: path, resolved: resolved) { + controller.setBadgeIdentifier(badgeID(for: repoStatus.badge), for: url) + return + } + + // Unknown path under a monitored root: no badge. Nudge the monitor so + // its next ambient scan picks up any new repo here; prefillBadges + // then paints the real (or grey-ambient) badge on reload. + requestRefresh(path: resolved) + } + + /// Look up a repo status under both the raw URL path and the + /// alias-normalized path. Needed because Finder may present folders reached + /// through volume aliases (e.g. /Volumes/Manuel-SSD-4TB -> /) with the + /// alias prefix, while the monitor writes canonical paths to status.json. + private func repoLookup(path: String, resolved: String) -> FinderRepoStatus? { + if let hit = statusReader.repoStatus(forPath: path) { return hit } + if resolved != path, let hit = statusReader.repoStatus(forPath: resolved) { + return hit + } + return nil + } + + private func orgFolderLookup(path: String, resolved: String) -> OrgFolderInfo? { + if let hit = statusReader.orgFolder(forPath: path) { return hit } + if resolved != path, let hit = statusReader.orgFolder(forPath: resolved) { + return hit + } + return nil + } + + private func badgeID(for badge: Badge) -> String { + switch badge { + case .green: return GitSameBadgesConstants.BadgeID.green + case .blue: return GitSameBadgesConstants.BadgeID.blue + case .orange: return GitSameBadgesConstants.BadgeID.orange + case .red: return GitSameBadgesConstants.BadgeID.red + case .gray: return GitSameBadgesConstants.BadgeID.gray + } + } + + /// Push the final badge for every known repo and org/user folder into + /// Finder's badge cache. Called on cold start, on every status.json + /// reload, and whenever directoryURLs changes. Idempotent: duplicate + /// writes are free per Apple's docs ("if the identifier matches the badge + /// in use, Finder takes no action"), so we can call this liberally. + private func prefillBadges() { + guard let status = statusReader.currentStatus else { return } + let controller = FIFinderSyncController.default() + + for orgFolder in status.orgFolders ?? [] { + let url = URL(fileURLWithPath: orgFolder.path) + let finalID = orgFolder.ownerType == .user + ? GitSameBadgesConstants.BadgeID.user + : GitSameBadgesConstants.BadgeID.org + controller.setBadgeIdentifier(finalID, for: url) + } + + for repo in status.repos { + let url = URL(fileURLWithPath: repo.path) + controller.setBadgeIdentifier(badgeID(for: repo.badge), for: url) + } + } + + private func requestRefresh(path: String) { + let now = Date() + if let last = lastRefreshRequest[path], + now.timeIntervalSince(last) < Self.refreshThrottle + { + return + } + lastRefreshRequest[path] = now + socketClient.send("REFRESH \(path)") { _ in } + } + + // MARK: - Toolbar + + override var toolbarItemName: String { + return "Git-Same" + } + + override var toolbarItemToolTip: String { + return "Git-Same repository status" + } + + override var toolbarItemImage: NSImage { + return NSImage(named: NSImage.folderName)! + } + + // MARK: - Context Menu + + override func menu(for menuKind: FIMenuKind) -> NSMenu { + guard let targetURL = FIFinderSyncController.default().targetedURL() else { + return NSMenu() + } + + let path = targetURL.path + let resolved = canonicalFromAlias(path) + let status = statusReader.currentStatus + let timestamp = status?.timestamp + + if let repoStatus = repoLookup(path: path, resolved: resolved) { + let workspaceInfo = repoStatus.workspace.flatMap { name in + status?.workspaces.first { $0.name == name } + } + return ContextMenuBuilder.build( + for: repoStatus, + workspaceInfo: workspaceInfo, + timestamp: timestamp, + socketClient: socketClient + ) + } + + if let orgFolder = orgFolderLookup(path: path, resolved: resolved) { + let orgRepos = (status?.repos ?? []).filter { + $0.org == orgFolder.org && $0.workspace == orgFolder.workspace + } + let workspaceInfo = status?.workspaces.first { $0.name == orgFolder.workspace } + return ContextMenuBuilder.build( + for: orgFolder, + repos: orgRepos, + workspaceInfo: workspaceInfo, + timestamp: timestamp + ) + } + + return NSMenu() + } + + // MARK: - Context Menu Actions + + @objc func refreshStatus(_ sender: Any?) { + socketClient.send("REFRESH_ALL") { _ in } + } + + @objc func openInTerminal(_ sender: Any?) { + guard let targetURL = FIFinderSyncController.default().targetedURL() else { return } + NSWorkspace.shared.open( + [targetURL], + withApplicationAt: URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app"), + configuration: NSWorkspace.OpenConfiguration() + ) + } +} diff --git a/macos/Shared/Constants.swift b/macos/Shared/Constants.swift new file mode 100644 index 0000000..89be1b3 --- /dev/null +++ b/macos/Shared/Constants.swift @@ -0,0 +1,67 @@ +// Constants.swift +// Shared constants between the host app and Badges (FinderSync) extension. +// +// IPC paths: +// - Production: resolved through `containerURL(forSecurityApplicationGroupIdentifier:)` +// using `appGroupIdentifier`, giving `~/Library/Group Containers/group..com.zaai.git-same/`. +// - Fallback (unsigned dev builds, formula installs): the legacy +// `~/.config/git-same/finder/` path. This branch is only taken when the +// app group container URL is nil, which happens when the running binary +// does not declare `com.apple.security.application-groups` (e.g. +// `tauri dev` or a `cargo run` of the monitor without the bundled +// entitlements). + +import Foundation + +enum GitSameBadgesConstants { + /// App group shared between the host app, the Badges extension, and the + /// monitor. Apple requires the team-id prefix. + /// Mirrors the Rust `git_same_core::ipc::APP_GROUP_ID`. + static let appGroupIdentifier = "group.57KL6Y7V32.com.zaai.git-same" + + /// Real $HOME, bypassing the sandbox container redirect that + /// FileManager.default.homeDirectoryForCurrentUser applies. Used only by + /// the legacy fallback paths below. + private static var realHomeDirectory: String { + if let pw = getpwuid(getuid()), let home = pw.pointee.pw_dir { + return String(cString: home) + } + return NSHomeDirectory() + } + + /// Directory containing IPC files (status.json, finder.sock). + /// + /// Returns the app group container directory in production. Falls back to + /// `~/.config/git-same/finder/` when the container URL is unavailable + /// (unsigned dev builds, or non-cask installs where the entitlement is + /// not present). + static var ipcDirectory: String { + if let url = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) { + return url.path + } + return "\(realHomeDirectory)/.config/git-same/finder" + } + + /// Path to the status JSON file. + static var statusFilePath: String { + return "\(ipcDirectory)/status.json" + } + + /// Path to the Unix socket for refresh requests. + static var socketPath: String { + return "\(ipcDirectory)/finder.sock" + } + + /// Badge identifiers used by FinderSync. + enum BadgeID { + static let green = "git-green" + static let blue = "git-blue" + static let orange = "git-orange" + static let red = "git-red" + static let gray = "git-gray" + static let org = "org" + static let user = "user" + } +} diff --git a/macos/Shared/SocketProtocol.swift b/macos/Shared/SocketProtocol.swift new file mode 100644 index 0000000..6e31f6e --- /dev/null +++ b/macos/Shared/SocketProtocol.swift @@ -0,0 +1,87 @@ +// SocketProtocol.swift +// Unix socket client for sending refresh requests to the monitor. + +import Foundation +import Network + +/// Client for communicating with the git-same monitor via Unix socket. +class SocketClient { + private let socketPath: String + + init(socketPath: String = GitSameBadgesConstants.socketPath) { + self.socketPath = socketPath + } + + /// Send a command to the monitor and receive the response. + func send(_ command: String, completion: @escaping (Swift.Result) -> Void) { + let endpoint = NWEndpoint.unix(path: socketPath) + let connection = NWConnection(to: endpoint, using: .tcp) + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + let message = "\(command)\n" + let data = message.data(using: .utf8)! + connection.send(content: data, completion: .contentProcessed { error in + if let error = error { + completion(.failure(error)) + connection.cancel() + return + } + // Read response + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in + if let error = error { + completion(.failure(error)) + } else if let data = data, let response = String(data: data, encoding: .utf8) { + completion(.success(response)) + } else { + completion(.success("")) + } + connection.cancel() + } + }) + case .failed(let error): + completion(.failure(error)) + default: + break + } + } + + connection.start(queue: .global(qos: .utility)) + } + + /// Ping the monitor. Returns true if it responds. + func ping(completion: @escaping (Bool) -> Void) { + send("PING") { result in + switch result { + case .success(let response): + completion(response.trimmingCharacters(in: .whitespacesAndNewlines) == "PONG") + case .failure: + completion(false) + } + } + } + + /// Request a refresh of a specific path. + func refresh(path: String, completion: @escaping (Bool) -> Void) { + send("REFRESH \(path)") { result in + completion(result.isSuccess) + } + } + + /// Request a full refresh. + func refreshAll(completion: @escaping (Bool) -> Void) { + send("REFRESH_ALL") { result in + completion(result.isSuccess) + } + } +} + +private extension Swift.Result { + var isSuccess: Bool { + switch self { + case .success: return true + case .failure: return false + } + } +} diff --git a/macos/Shared/StatusModels.swift b/macos/Shared/StatusModels.swift new file mode 100644 index 0000000..13bb2b8 --- /dev/null +++ b/macos/Shared/StatusModels.swift @@ -0,0 +1,140 @@ +// StatusModels.swift +// Codable types matching the monitor's finder-status.json schema. + +import Foundation + +/// Badge color indicating repository health. +/// +/// `gray` marks ambient (non-workspace) repos that haven't been fully scanned +/// yet — they upgrade to a semantic color when the user opens their context +/// menu. +enum Badge: String, Codable { + case green + case blue + case orange + case red + case gray +} + +/// Branch sync status. +struct FinderBranchInfo: Codable { + let name: String + let upstream: String? + let ahead: UInt32 + let behind: UInt32 + let synced: Bool +} + +/// Remote info. +struct FinderRemoteInfo: Codable { + let name: String + let url: String +} + +/// Worktree info. +struct FinderWorktreeInfo: Codable { + let path: String + let branch: String? + let synced: Bool +} + +/// Complete status for a single repository. +struct FinderRepoStatus: Codable { + let path: String + let workspace: String? + let org: String? + let badge: Badge + let currentBranch: String + let defaultBranch: String? + let commitCount: UInt64 + let stagedCount: Int + let unstagedCount: Int + let untrackedCount: Int + let ahead: UInt32 + let behind: UInt32 + let stashCount: Int + let hasImportantIgnoredFiles: Bool + let importantIgnoredFiles: [String]? + let branches: [FinderBranchInfo] + let allBranchesSynced: Bool + let remotes: [FinderRemoteInfo] + let worktrees: [FinderWorktreeInfo] + let allWorktreesSynced: Bool + + enum CodingKeys: String, CodingKey { + case path, workspace, org, badge + case currentBranch = "current_branch" + case defaultBranch = "default_branch" + case commitCount = "commit_count" + case stagedCount = "staged_count" + case unstagedCount = "unstaged_count" + case untrackedCount = "untracked_count" + case ahead, behind + case stashCount = "stash_count" + case hasImportantIgnoredFiles = "has_important_ignored_files" + case importantIgnoredFiles = "important_ignored_files" + case branches + case allBranchesSynced = "all_branches_synced" + case remotes, worktrees + case allWorktreesSynced = "all_worktrees_synced" + } +} + +/// Classification of the account that owns an org/user folder. +enum OwnerType: String, Codable { + case user + case organization + case unknown +} + +/// Organization or user folder inside a workspace. +struct OrgFolderInfo: Codable { + let path: String + let org: String + let workspace: String + let ownerType: OwnerType? + + enum CodingKeys: String, CodingKey { + case path, org, workspace + case ownerType = "owner_type" + } +} + +/// Workspace summary. +struct FinderWorkspaceInfo: Codable { + let name: String + let root: String + let orgs: [String] +} + +/// Top-level status file written by the monitor. +struct FinderStatus: Codable { + let version: UInt32 + let timestamp: String + let daemonPid: UInt32 + let workspaces: [FinderWorkspaceInfo] + let customFolders: [String]? + let repos: [FinderRepoStatus] + let orgFolders: [OrgFolderInfo]? + /// Union of workspace roots and ambient scan roots. The extension uses + /// this as `FIFinderSyncController.directoryURLs` so Finder knows which + /// folders to ask about. + let monitoredRoots: [String]? + /// `/Volumes/` boot-volume-alias prefixes computed by the monitor. + /// The extension uses these as pure-string prefixes to map alias-presented + /// Finder paths back to canonical keys, so it never calls + /// `resolvingSymlinksInPath()` and never touches the disk outside its + /// app-group container. + let bootVolumeAliases: [String]? + + enum CodingKeys: String, CodingKey { + case version, timestamp + case daemonPid = "daemon_pid" + case workspaces + case customFolders = "custom_folders" + case repos + case orgFolders = "org_folders" + case monitoredRoots = "monitored_roots" + case bootVolumeAliases = "boot_volume_aliases" + } +} diff --git a/macos/Shared/StatusReader.swift b/macos/Shared/StatusReader.swift new file mode 100644 index 0000000..ef92f25 --- /dev/null +++ b/macos/Shared/StatusReader.swift @@ -0,0 +1,129 @@ +// StatusReader.swift +// Watches the monitor's status.json file and parses it. + +import Foundation + +/// Reads and watches the Finder status JSON file. +class StatusReader { + static let shared = StatusReader() + + /// Callback invoked when the status file changes. + var onStatusUpdate: (() -> Void)? + + /// The current parsed status. + private(set) var currentStatus: FinderStatus? + + /// Lookup cache for repo status by path. + private var reposByPath: [String: FinderRepoStatus] = [:] + + /// Lookup cache for org folders by path. + private var orgFoldersByPath: [String: OrgFolderInfo] = [:] + + private var fileMonitor: DispatchSourceFileSystemObject? + private var fileDescriptor: Int32 = -1 + + private init() { + reload() + } + + /// Start watching the status file for changes. + func startWatching() { + let path = GitSameBadgesConstants.statusFilePath + + // Open file descriptor for monitoring + fileDescriptor = open(path, O_EVTONLY) + guard fileDescriptor >= 0 else { + // File doesn't exist yet — try again periodically + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 5) { [weak self] in + self?.startWatching() + } + return + } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: [.write, .rename, .delete], + queue: DispatchQueue.global(qos: .utility) + ) + + source.setEventHandler { [weak self] in + self?.reload() + DispatchQueue.main.async { + self?.onStatusUpdate?() + } + } + + source.setCancelHandler { [weak self] in + if let fd = self?.fileDescriptor, fd >= 0 { + close(fd) + self?.fileDescriptor = -1 + } + } + + fileMonitor = source + source.resume() + + // The DispatchSource only fires on subsequent writes. If the file + // already exists when we first successfully open it (e.g. the + // extension started before the monitor and this is a retry that + // finally caught the file), seed currentStatus now so observers see + // the status without waiting for the next monitor write. + reload() + DispatchQueue.main.async { [weak self] in + self?.onStatusUpdate?() + } + } + + /// Stop watching the status file. + func stopWatching() { + fileMonitor?.cancel() + fileMonitor = nil + } + + /// Reload and parse the status file. + func reload() { + let path = GitSameBadgesConstants.statusFilePath + guard let data = FileManager.default.contents(atPath: path) else { return } + + do { + let decoder = JSONDecoder() + let status = try decoder.decode(FinderStatus.self, from: data) + + // Update lookup caches + var repoMap: [String: FinderRepoStatus] = [:] + for repo in status.repos { + repoMap[repo.path] = repo + } + + var orgMap: [String: OrgFolderInfo] = [:] + for org in status.orgFolders ?? [] { + orgMap[org.path] = org + } + + self.currentStatus = status + self.reposByPath = repoMap + self.orgFoldersByPath = orgMap + } catch { + // Ignore parse errors (file might be mid-write, though atomic rename should prevent this) + } + } + + /// Get the status for a repo at the given path. + func repoStatus(forPath path: String) -> FinderRepoStatus? { + return reposByPath[path] + } + + /// Check if the given path is an org folder. + func isOrgFolder(path: String) -> Bool { + return orgFoldersByPath[path] != nil + } + + /// Get the org-folder info for the given path, if any. + func orgFolder(forPath path: String) -> OrgFolderInfo? { + return orgFoldersByPath[path] + } + + deinit { + stopWatching() + } +} diff --git a/macos/com.zaai.git-same.monitor.plist b/macos/com.zaai.git-same.monitor.plist new file mode 100644 index 0000000..326217c --- /dev/null +++ b/macos/com.zaai.git-same.monitor.plist @@ -0,0 +1,23 @@ + + + + + Label + com.zaai.git-same.monitor + ProgramArguments + + __GIT_SAME_MONITOR_BINARY__ + monitor + --foreground + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/git-same-monitor.log + StandardErrorPath + /tmp/git-same-monitor.err + + diff --git a/src/commands/status.rs b/src/commands/status.rs deleted file mode 100644 index 2c19026..0000000 --- a/src/commands/status.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! Status command handler. - -use crate::cli::StatusArgs; -use crate::config::{Config, WorkspaceManager}; -use crate::discovery::DiscoveryOrchestrator; -use crate::errors::Result; -use crate::git::{GitOperations, ShellGit}; -use crate::output::{format_count, Output}; - -/// Show status of repositories. -pub async fn run(args: &StatusArgs, config: &Config, output: &Output) -> Result<()> { - let workspace = WorkspaceManager::resolve(args.workspace.as_deref(), config)?; - - // Ensure base path exists (offer to fix if user moved it) - super::ensure_base_path(&workspace, output)?; - let base_path = workspace.expanded_base_path(); - - let structure = workspace.structure.as_deref().unwrap_or(&config.structure); - - // Scan local repositories - let git = ShellGit::new(); - let orchestrator = DiscoveryOrchestrator::new(workspace.filters.clone(), structure.to_string()); - let local_repos = orchestrator.scan_local(&base_path, &git); - - if local_repos.is_empty() { - output.warn("No repositories found"); - return Ok(()); - } - - output.info(&format_count(local_repos.len(), "repositories found")); - - // Get status for each - let mut uncommitted_count = 0; - let mut behind_count = 0; - let mut error_count = 0; - - for (path, org, name) in &local_repos { - let status = git.status(path); - - match status { - Ok(s) => { - let is_uncommitted = s.is_uncommitted || s.has_untracked; - let is_behind = s.behind > 0; - - // Apply filters - if args.uncommitted && !is_uncommitted { - continue; - } - if args.behind && !is_behind { - continue; - } - if !args.org.is_empty() && !args.org.contains(org) { - continue; - } - - if is_uncommitted { - uncommitted_count += 1; - } - if is_behind { - behind_count += 1; - } - - // Print status - let full_name = format!("{}/{}", org, name); - if args.detailed { - println!("{}", full_name); - println!(" Branch: {}", s.branch); - if s.ahead > 0 || s.behind > 0 { - println!(" Ahead: {}, Behind: {}", s.ahead, s.behind); - } - if s.is_uncommitted { - println!(" Status: uncommitted changes"); - } - if s.has_untracked { - println!(" Status: has untracked files"); - } - } else { - let mut indicators = Vec::new(); - if is_uncommitted { - indicators.push("*".to_string()); - } - if s.ahead > 0 { - indicators.push(format!("+{}", s.ahead)); - } - if s.behind > 0 { - indicators.push(format!("-{}", s.behind)); - } - - if indicators.is_empty() { - println!(" {} (clean)", full_name); - } else { - println!(" {} [{}]", full_name, indicators.join(", ")); - } - } - } - Err(e) => { - error_count += 1; - output.verbose(&format!(" {}/{} - error: {}", org, name, e)); - } - } - } - - // Summary - println!(); - if uncommitted_count > 0 { - output.warn(&format!( - "{} repositories have uncommitted changes", - uncommitted_count - )); - } - if behind_count > 0 { - output.info(&format!( - "{} repositories are behind upstream", - behind_count - )); - } - if error_count > 0 { - output.warn(&format!( - "{} repositories could not be checked", - error_count - )); - } else if uncommitted_count == 0 && behind_count == 0 { - output.success("All repositories are clean and up to date"); - } - - Ok(()) -} - -#[cfg(test)] -#[path = "status_tests.rs"] -mod tests; diff --git a/src/git/shell_tests.rs b/src/git/shell_tests.rs deleted file mode 100644 index 96bc1e5..0000000 --- a/src/git/shell_tests.rs +++ /dev/null @@ -1,122 +0,0 @@ -use super::*; - -#[test] -fn test_shell_git_creation() { - let _git = ShellGit::new(); - // ShellGit is a zero-sized type with no fields -} - -#[test] -fn test_parse_branch_info_simple() { - let git = ShellGit::new(); - let (branch, ahead, behind) = git.parse_branch_info("## main"); - assert_eq!(branch, "main"); - assert_eq!(ahead, 0); - assert_eq!(behind, 0); -} - -#[test] -fn test_parse_branch_info_with_tracking() { - let git = ShellGit::new(); - let (branch, ahead, behind) = git.parse_branch_info("## main...origin/main"); - assert_eq!(branch, "main"); - assert_eq!(ahead, 0); - assert_eq!(behind, 0); -} - -#[test] -fn test_parse_branch_info_ahead() { - let git = ShellGit::new(); - let (branch, ahead, behind) = git.parse_branch_info("## feature...origin/feature [ahead 3]"); - assert_eq!(branch, "feature"); - assert_eq!(ahead, 3); - assert_eq!(behind, 0); -} - -#[test] -fn test_parse_branch_info_behind() { - let git = ShellGit::new(); - let (branch, ahead, behind) = git.parse_branch_info("## main...origin/main [behind 5]"); - assert_eq!(branch, "main"); - assert_eq!(ahead, 0); - assert_eq!(behind, 5); -} - -#[test] -fn test_parse_branch_info_diverged() { - let git = ShellGit::new(); - let (branch, ahead, behind) = - git.parse_branch_info("## develop...origin/develop [ahead 2, behind 7]"); - assert_eq!(branch, "develop"); - assert_eq!(ahead, 2); - assert_eq!(behind, 7); -} - -#[test] -fn test_parse_status_clean() { - let git = ShellGit::new(); - let status = git.parse_status_output("", "## main...origin/main"); - assert!(!status.is_uncommitted); - assert!(!status.has_untracked); - assert_eq!(status.branch, "main"); -} - -#[test] -fn test_parse_status_modified() { - let git = ShellGit::new(); - let status = git.parse_status_output(" M src/main.rs", "## main"); - assert!(status.is_uncommitted); - assert!(!status.has_untracked); -} - -#[test] -fn test_parse_status_untracked() { - let git = ShellGit::new(); - let status = git.parse_status_output("?? newfile.txt", "## main"); - assert!(!status.is_uncommitted); - assert!(status.has_untracked); -} - -#[test] -fn test_parse_status_mixed() { - let git = ShellGit::new(); - let output = " M src/main.rs\n?? newfile.txt\nA staged.rs"; - let status = git.parse_status_output(output, "## feature [ahead 1, behind 2]"); - assert!(status.is_uncommitted); - assert!(status.has_untracked); - assert_eq!(status.branch, "feature"); - assert_eq!(status.ahead, 1); - assert_eq!(status.behind, 2); -} - -// Integration tests that require actual git repo -#[test] -#[ignore] // Run with: cargo test -- --ignored -fn test_is_repo_real() { - let git = ShellGit::new(); - // Current directory should be a git repo - assert!(git.is_repo(Path::new("."))); - // Root is not a git repo - assert!(!git.is_repo(Path::new("/"))); -} - -#[test] -#[ignore] -fn test_current_branch_real() { - let git = ShellGit::new(); - let branch = git.current_branch(Path::new(".")); - assert!(branch.is_ok()); - // Should return some branch name - assert!(!branch.unwrap().is_empty()); -} - -#[test] -#[ignore] -fn test_status_real() { - let git = ShellGit::new(); - let status = git.status(Path::new(".")); - assert!(status.is_ok()); - let status = status.unwrap(); - // Should have a branch - assert!(!status.branch.is_empty()); -} diff --git a/src/infra/mod.rs b/src/infra/mod.rs deleted file mode 100644 index 958b19f..0000000 --- a/src/infra/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Infrastructure adapters (I/O, provider and git bindings). - -pub mod storage; - -pub use crate::git; -pub use crate::provider; diff --git a/src/infra/storage/mod.rs b/src/infra/storage/mod.rs deleted file mode 100644 index 8869004..0000000 --- a/src/infra/storage/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Storage layer adapters. - -pub use crate::cache::*; -pub use crate::config::workspace_manager::*; diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index c2da655..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! # Git-Same - Mirror GitHub org/repo structure locally -//! -//! Git-Same is a CLI tool that discovers all GitHub organizations -//! and repositories you have access to, then clones them to your local filesystem -//! maintaining the org/repo directory structure. -//! -//! ## Features -//! -//! - **Multi-Provider Support**: Works with GitHub (more providers coming soon) -//! - **Parallel Operations**: Clones and syncs repositories concurrently -//! - **Smart Filtering**: Filter by archived status, forks, organizations -//! - **Incremental Sync**: Only fetches/pulls what has changed -//! - **Progress Reporting**: Beautiful progress bars and status updates -//! -//! ## Available Commands -//! -//! The tool can be invoked using any of these names (all installed by default): -//! - `git-same` - Main command -//! - `gitsame` - No hyphen variant -//! - `gitsa` - Short form -//! - `gisa` - Shortest variant -//! - `git same` - Git subcommand (requires git-same in PATH) -//! -//! ## Example -//! -//! ```bash -//! # Initialize configuration -//! git-same init -//! -//! # Set up a workspace -//! git-same setup -//! -//! # Sync repositories (clone new + fetch existing) -//! git-same sync --dry-run -//! git-same sync -//! -//! # Show status -//! git-same status -//! -//! # Also works as git subcommand -//! git same sync -//! ``` - -pub mod app; -pub mod auth; -pub mod banner; -pub mod cache; -pub mod checks; -pub mod cli; -pub mod commands; -pub mod config; -pub mod discovery; -pub mod domain; -pub mod errors; -pub mod git; -pub mod infra; -pub mod operations; -pub mod output; -pub mod provider; -#[cfg(feature = "tui")] -pub mod setup; -#[cfg(feature = "tui")] -pub mod tui; -pub mod types; -pub mod workflows; - -/// Re-export commonly used types for convenience. -pub mod prelude { - pub use crate::auth::{get_auth, get_auth_for_provider, AuthResult}; - pub use crate::cache::{CacheManager, DiscoveryCache, CACHE_VERSION}; - pub use crate::cli::{Cli, Command, InitArgs, ResetArgs, StatusArgs, SyncCmdArgs}; - pub use crate::config::{ - Config, ConfigCloneOptions, FilterOptions, SyncMode as ConfigSyncMode, WorkspaceConfig, - WorkspaceProvider, - }; - pub use crate::discovery::DiscoveryOrchestrator; - pub use crate::domain::RepoPathTemplate; - pub use crate::errors::{AppError, GitError, ProviderError, Result}; - pub use crate::git::{ - CloneOptions, FetchResult, GitOperations, PullResult, RepoStatus, ShellGit, - }; - pub use crate::operations::clone::{ - CloneManager, CloneManagerOptions, CloneProgress, CloneResult, - }; - pub use crate::operations::sync::{ - LocalRepo, SyncManager, SyncManagerOptions, SyncMode, SyncResult, - }; - pub use crate::output::{ - CloneProgressBar, DiscoveryProgressBar, Output, SyncProgressBar, Verbosity, - }; - pub use crate::provider::{ - create_provider, Credentials, DiscoveryOptions, DiscoveryProgress, NoProgress, Provider, - RateLimitInfo, - }; - pub use crate::types::{ActionPlan, OpResult, OpSummary, Org, OwnedRepo, ProviderKind, Repo}; -} - -#[cfg(test)] -#[path = "lib_tests.rs"] -mod tests; diff --git a/toolkit/conductor/run.sh b/toolkit/conductor/run.sh index da80e50..682e3c9 100755 --- a/toolkit/conductor/run.sh +++ b/toolkit/conductor/run.sh @@ -31,25 +31,46 @@ fi PRIMARY_BIN="${BINARIES[0]}" GS_COMMAND="$CARGO_BIN_DIR/$PRIMARY_BIN" -# Install primary binary -echo "Installing with: cargo install --path . --force" -cargo install --path . --force +# Build the CLI in the workspace target/ so deps are cached and shared +# with `tauri dev` below. Debug by default; opt into release via +# GIT_SAME_PROFILE=release for parity with shipped binaries. +PROFILE="${GIT_SAME_PROFILE:-debug}" +case "$PROFILE" in + debug) + echo "Building (debug): cargo build -p git-same" + cargo build -p git-same + BUILT_BIN="$PROJECT_DIR/target/debug/git-same" + ;; + release) + echo "Building (release): cargo build -p git-same --release" + cargo build -p git-same --release + BUILT_BIN="$PROJECT_DIR/target/release/git-same" + ;; + *) + echo "ERROR: GIT_SAME_PROFILE must be 'debug' or 'release' (got '$PROFILE')." + exit 1 + ;; +esac echo "" -if [ ! -x "$CARGO_BIN_DIR/$PRIMARY_BIN" ]; then - echo "ERROR: $PRIMARY_BIN installation failed." +if [ ! -x "$BUILT_BIN" ]; then + echo "ERROR: build did not produce $BUILT_BIN" exit 1 fi -# Create alias symlinks from manifest (skip primary) -for alias in "${BINARIES[@]:1}"; do - # Replace stale standalone alias binaries with a symlink to the primary binary. - if [ -e "$CARGO_BIN_DIR/$alias" ] && [ ! -L "$CARGO_BIN_DIR/$alias" ]; then - rm -f "$CARGO_BIN_DIR/$alias" +mkdir -p "$CARGO_BIN_DIR" + +# Symlink the primary name + every alias at the freshly built binary. +# Replace any prior non-symlink (e.g. a copy left by an old `cargo install`) +# so the new symlink takes precedence on PATH. +for name in "${BINARIES[@]}"; do + target_path="$CARGO_BIN_DIR/$name" + if [ -e "$target_path" ] && [ ! -L "$target_path" ]; then + rm -f "$target_path" fi - ln -sf "$CARGO_BIN_DIR/$PRIMARY_BIN" "$CARGO_BIN_DIR/$alias" - echo " Symlinked: $alias -> $PRIMARY_BIN" + ln -sf "$BUILT_BIN" "$target_path" done +echo " Linked $PRIMARY_BIN + ${#BINARIES[@]} name(s) -> $BUILT_BIN" echo "" # Warn if gisa is also installed elsewhere (e.g. Homebrew) @@ -73,6 +94,10 @@ echo "" echo " $GS_COMMAND init # Create config file" echo " $GS_COMMAND setup # Interactive workspace wizard" echo "" +echo "Interactive TUI:" +echo "" +echo " $GS_COMMAND # Launch TUI (no subcommand)" +echo "" echo "Sync repos (discover + clone new + fetch existing):" echo "" echo " $GS_COMMAND sync --dry-run # Preview what would happen" @@ -80,18 +105,39 @@ echo " $GS_COMMAND sync # Run sync (fetch mode)" echo " $GS_COMMAND sync --pull # Sync with pull instead of fetch" echo " $GS_COMMAND sync --workspace github # Sync specific workspace" echo " $GS_COMMAND sync --concurrency 8 # Control parallelism" +echo " $GS_COMMAND sync --refresh # Ignore cache, re-discover repos" +echo " $GS_COMMAND sync --no-skip-uncommitted # Don't skip dirty repos" echo "" echo "Status:" echo "" echo " $GS_COMMAND status # Show all repo status" echo " $GS_COMMAND status --uncommitted # Only repos with changes" +echo " $GS_COMMAND status --behind # Only repos behind upstream" echo " $GS_COMMAND status --detailed # Full detail per repo" +echo " $GS_COMMAND status --org my-org # Filter to one org (repeatable)" echo "" echo "Workspace management:" echo "" echo " $GS_COMMAND workspace list # List configured workspaces" -echo " $GS_COMMAND workspace default my-ws # Set default workspace" echo " $GS_COMMAND workspace default # Show current default" +echo " $GS_COMMAND workspace default my-ws # Set default workspace" +echo " $GS_COMMAND workspace default --clear # Clear the default" +echo "" +echo "Scan for unregistered workspaces:" +echo "" +echo " $GS_COMMAND scan # Scan current directory" +echo " $GS_COMMAND scan ~/projects # Scan a specific directory" +echo " $GS_COMMAND scan --depth 3 # Limit search depth" +echo " $GS_COMMAND scan ~/projects --register # Auto-register found workspaces" +echo "" +echo "Finder extension monitor (macOS):" +echo "" +echo " $GS_COMMAND monitor # Start the monitor" +echo " $GS_COMMAND monitor --interval 60 # Poll every 60 seconds" +echo " $GS_COMMAND monitor --status # Check if the monitor is running" +echo " $GS_COMMAND monitor --stop # Stop a running monitor" +echo " $GS_COMMAND refresh # Force immediate status.json rewrite" +echo " $GS_COMMAND refresh --path ~/work/org # Refresh a single folder" echo "" echo "Reset / cleanup:" echo "" @@ -103,3 +149,36 @@ echo "" echo " $GS_COMMAND -v sync --dry-run" echo " $GS_COMMAND --json status" echo "" + +# Launch the Tauri desktop app in dev mode +TAURI_CLI="$PROJECT_DIR/crates/git-same-app/ui/node_modules/.bin/tauri" +if [ ! -x "$TAURI_CLI" ]; then + echo "ERROR: Tauri CLI not found at $TAURI_CLI" + echo "Run ./toolkit/conductor/setup.sh first to install frontend dependencies." + exit 1 +fi + +APP_PORT="${GIT_SAME_APP_PORT:-${CONDUCTOR_PORT:-${PORT:-1420}}}" +if ! [[ "$APP_PORT" =~ ^[0-9]+$ ]] || [ "$APP_PORT" -lt 1 ] || [ "$APP_PORT" -gt 65535 ]; then + echo "ERROR: Invalid app port '$APP_PORT'. Set GIT_SAME_APP_PORT to a value from 1-65535." + exit 1 +fi +export GIT_SAME_APP_PORT="$APP_PORT" + +TAURI_DEV_CONFIG="$(mktemp -t git-same-tauri-dev.XXXXXX.json)" +trap 'rm -f "$TAURI_DEV_CONFIG"' EXIT +cat > "$TAURI_DEV_CONFIG" < /dev/null; then + echo "ERROR: Node.js not found." + echo "Install with: brew install node" + echo "Or via nvm: https://github.com/nvm-sh/nvm" + exit 1 +fi +echo "node: $(node --version)" +echo "" + +# Enable pnpm via Corepack +echo "--- Enabling pnpm (Corepack) ---" +if ! command -v corepack &> /dev/null; then + echo "ERROR: Corepack not found. Requires Node.js 16.10+." + echo "Reinstall Node or run: npm install -g corepack" + exit 1 +fi +corepack enable pnpm +echo "pnpm: $(corepack pnpm --version)" +echo "" + +# Install Tauri app frontend dependencies +echo "--- Installing Tauri app frontend dependencies ---" +UI_DIR="$PROJECT_DIR/crates/git-same-app/ui" +if ! corepack pnpm --dir "$UI_DIR" install --frozen-lockfile; then + echo "WARNING: --frozen-lockfile failed, retrying without it." + corepack pnpm --dir "$UI_DIR" install +fi +echo "" + +# Sanity-check Tauri CLI +echo "--- Checking Tauri CLI ---" +TAURI_CLI="$UI_DIR/node_modules/.bin/tauri" +if [ ! -x "$TAURI_CLI" ] || ! "$TAURI_CLI" --version &> /dev/null; then + echo "ERROR: Tauri CLI not runnable at $TAURI_CLI" + echo "Re-run: corepack pnpm --dir $UI_DIR install" + exit 1 +fi +echo "tauri: $("$TAURI_CLI" --version)" +echo "" + +# Reset Rust state, then warm the workspace target/ cache so the next +# run.sh is a tight incremental build instead of a full cold compile. echo "--- Cleaning Build Cache ---" cargo clean echo "" echo "--- Updating Dependencies ---" cargo update echo "" +echo "--- Pre-building workspace (debug) to warm target/ cache ---" +cargo build --workspace +echo "" echo "========================================" echo " Setup Complete!" @@ -72,6 +118,7 @@ echo "========================================" echo "" echo "Next steps:" echo " 1. Run: ./toolkit/conductor/run.sh" -echo " 2. Or manually install: cargo install --path . --force" +echo " (installs the CLI/TUI and launches the Tauri app in dev mode)" +echo " 2. Or manually install: cargo install --path crates/git-same-cli --force" echo " (then refresh aliases via ./toolkit/conductor/run.sh)" echo "" diff --git a/toolkit/homebrew/cask.rb.tmpl b/toolkit/homebrew/cask.rb.tmpl index b4bbd41..9354ca7 100644 --- a/toolkit/homebrew/cask.rb.tmpl +++ b/toolkit/homebrew/cask.rb.tmpl @@ -11,9 +11,9 @@ cask "git-same" do sha256 arm: "SHA_AARCH64_PLACEHOLDER", intel: "SHA_X86_64_PLACEHOLDER" - url "https://github.com/zaai-com/git-same/releases/download/#{version}/git-same-#{version}-#{arch}-apple-darwin.tar.gz" + url "https://github.com/zaai-com/git-same/releases/download/#{version}/git-same-#{version}-#{arch}.dmg" name "Git-Same" - desc "Discover and mirror GitHub org/repo structures locally" + desc "Git-Same-Badges" homepage "https://github.com/zaai-com/git-same" livecheck do @@ -21,23 +21,59 @@ cask "git-same" do strategy :github_latest end - depends_on macos: ">= :big_sur" - - # Casks don't have first-class shell-completion stanzas, so completions go - # through `binary` with absolute target: paths matching the locations the - # headless `git-same-cli` formula installs to. All `binary` stanzas must be - # grouped together per Cask/StanzaOrder; the manpage stanza follows. - binary "git-same" - binary "git-same", target: "gitsame" - binary "git-same", target: "gitsa" - binary "git-same", target: "gisa" - binary "_git-same", target: "#{HOMEBREW_PREFIX}/share/zsh/site-functions/_git-same" - binary "git-same.bash", target: "#{HOMEBREW_PREFIX}/etc/bash_completion.d/git-same" - binary "git-same.fish", target: "#{HOMEBREW_PREFIX}/share/fish/vendor_completions.d/git-same.fish" - manpage "git-same.1" + depends_on macos: ">= :ventura" + + app "Git-Same.app" + + binary "#{appdir}/Git-Same.app/Contents/Helpers/git-same" + binary "#{appdir}/Git-Same.app/Contents/Helpers/git-same", target: "gitsame" + binary "#{appdir}/Git-Same.app/Contents/Helpers/git-same", target: "gitsa" + binary "#{appdir}/Git-Same.app/Contents/Helpers/git-same", target: "gisa" + + postflight do + legacy_plist_dst = "#{Dir.home}/Library/LaunchAgents/com.zaai.git-same.daemon.plist" + if File.exist?(legacy_plist_dst) + system_command "/bin/launchctl", args: ["unload", legacy_plist_dst], sudo: false, must_succeed: false + File.delete(legacy_plist_dst) + end + + plist_src = "#{appdir}/Git-Same.app/Contents/Resources/com.zaai.git-same.monitor.plist" + plist_dst = "#{Dir.home}/Library/LaunchAgents/com.zaai.git-same.monitor.plist" + monitor_binary = "#{appdir}/Git-Same.app/Contents/Helpers/git-same" + + FileUtils.mkdir_p(File.dirname(plist_dst)) + rendered = File.read(plist_src).gsub("__GIT_SAME_MONITOR_BINARY__", monitor_binary) + File.write(plist_dst, rendered) + system_command "/bin/launchctl", args: ["unload", plist_dst], sudo: false, must_succeed: false + system_command "/bin/launchctl", args: ["load", plist_dst], sudo: false, must_succeed: false + + # Clear stale FinderSync registration from pre-rename builds (id was + # `com.zaai.git-same.GitSameBadge.FinderSync`; renamed to + # `com.zaai.git-same.badges` in 3.1.0). Best-effort: ignored if the id + # is not present in pluginkit's cache. + system_command "/usr/bin/pluginkit", + args: ["-e", "ignore", "-i", "com.zaai.git-same.GitSameBadge.FinderSync"], + sudo: false, must_succeed: false + end + + # Both labels listed for one release: `com.zaai.git-same.daemon` is the + # legacy label (3.0.x); `com.zaai.git-same.monitor` is the renamed agent + # introduced after the daemon→monitor rename. Cask upgrades from 3.0.x + # need the legacy label so launchctl unloads the old plist before the new + # one is installed. + uninstall launchctl: ["com.zaai.git-same.monitor", "com.zaai.git-same.daemon"], + delete: [ + "~/Library/LaunchAgents/com.zaai.git-same.monitor.plist", + "~/Library/LaunchAgents/com.zaai.git-same.daemon.plist", + ] zap trash: [ "~/.config/git-same", + "~/Library/Application Support/com.zaai.git-same", + "~/Library/Caches/com.zaai.git-same", "~/Library/Caches/git-same", + "~/Library/Group Containers/group.57KL6Y7V32.com.zaai.git-same", + "~/Library/LaunchAgents/com.zaai.git-same.monitor.plist", + "~/Library/LaunchAgents/com.zaai.git-same.daemon.plist", ] end diff --git a/toolkit/homebrew/render-cask.sh b/toolkit/homebrew/render-cask.sh index af02517..16600bd 100755 --- a/toolkit/homebrew/render-cask.sh +++ b/toolkit/homebrew/render-cask.sh @@ -30,8 +30,8 @@ usage() { Usage: $0 VERSION --sha-arm --sha-intel [--out PATH] VERSION Strict semver, no leading zeros, no v prefix (e.g. 3.0.1) - --sha-arm SHA256 of the aarch64 tarball (64 hex chars) - --sha-intel SHA256 of the x86_64 tarball (64 hex chars) + --sha-arm SHA256 of the aarch64 DMG (64 hex chars) + --sha-intel SHA256 of the x86_64 DMG (64 hex chars) --out PATH Write to PATH instead of stdout EOF } diff --git a/toolkit/icons/README.md b/toolkit/icons/README.md new file mode 100644 index 0000000..16e584c --- /dev/null +++ b/toolkit/icons/README.md @@ -0,0 +1,37 @@ +# Icons + +Tools for generating and promoting the Git-Same macOS app icon. + +The palette mirrors the TUI banner (`crates/git-same-cli/src/banner.rs`): +blue `#3B82F6`, cyan `#06B6D4`, green `#22C55E`. + +## Generate concept variants + +``` +swift toolkit/icons/generate-icons.swift --variant all +``` + +Writes five 1024×1024 PNGs into `crates/git-same-app/icons/variants/`: + +| Variant | Concept | +| ------------- | ---------------------------------------------------------------- | +| `twin` | Two overlapping list-tiles (the "same" / mirrored repos idea) | +| `sync` | Circular sync arrows around a small repo node | +| `folder-pair` | Front folder + back folder with a remote → local arrow | +| `wordmark` | `gs` monogram with `=` mirror cue | +| `tui-banner` | `g=s` typographic mark with TUI-banner double-rule framing | + +Single-variant render: + +``` +swift toolkit/icons/generate-icons.swift --variant twin --out /tmp +``` + +## Promote a chosen variant + +``` +bash toolkit/icons/promote.sh +``` + +Regenerates `1024x1024.png`, all sizes referenced by `tauri.conf.json`, +`icon.icns` (via `iconutil`), and `icon.ico` (via `sips`). diff --git a/toolkit/icons/build-workspace-folder-icns.sh b/toolkit/icons/build-workspace-folder-icns.sh new file mode 100755 index 0000000..060bd09 --- /dev/null +++ b/toolkit/icons/build-workspace-folder-icns.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Build crates/git-same-core/assets/workspace-folder.icns from the +# `folder-icon` variant in toolkit/icons/generate-icons.swift. +# +# This ICNS is embedded into the git-same binary via include_bytes! and painted +# onto every workspace root via NSWorkspace.setIcon (see +# crates/git-same-core/src/macos/folder_icon.rs). +# +# Usage: bash toolkit/icons/build-workspace-folder-icns.sh + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +GEN="$ROOT/toolkit/icons/generate-icons.swift" +OUT_DIR="$ROOT/crates/git-same-core/assets" +OUT="$OUT_DIR/workspace-folder.icns" + +if [ ! -f "$GEN" ]; then + echo "missing generator at $GEN" >&2 + exit 1 +fi + +mkdir -p "$OUT_DIR" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# 1. Render the folder-icon master at 1024. +swift "$GEN" --variant folder-icon --out "$TMP" --size 1024 >/dev/null +MASTER="$TMP/folder-icon.png" + +# 2. Assemble macOS .icns from an iconset (same size list promote.sh uses). +ISET="$TMP/WorkspaceFolder.iconset" +mkdir -p "$ISET" +declare -a entries=( + "16:icon_16x16.png" + "32:icon_16x16@2x.png" + "32:icon_32x32.png" + "64:icon_32x32@2x.png" + "128:icon_128x128.png" + "256:icon_128x128@2x.png" + "256:icon_256x256.png" + "512:icon_256x256@2x.png" + "512:icon_512x512.png" + "1024:icon_512x512@2x.png" +) +for entry in "${entries[@]}"; do + size="${entry%%:*}" + name="${entry##*:}" + cp "$MASTER" "$ISET/$name" + sips -Z "$size" "$ISET/$name" >/dev/null +done + +iconutil -c icns -o "$OUT" "$ISET" + +echo "wrote $OUT" +ls -lh "$OUT" diff --git a/toolkit/icons/generate-icons.swift b/toolkit/icons/generate-icons.swift new file mode 100755 index 0000000..9663bb8 --- /dev/null +++ b/toolkit/icons/generate-icons.swift @@ -0,0 +1,676 @@ +#!/usr/bin/env swift +// Generates Git-Same macOS app icon concept variants as 1024x1024 PNGs. +// +// Palette uses the macOS system colors that the Finder Badges register in +// macos/GitSameBadges/BadgeManager.swift (.systemBlue, .systemGreen, etc.), +// resolved to their sRGB light-mode values so PNGs stay deterministic. +// +// Usage: swift toolkit/icons/generate-icons.swift [--variant ] [--out ] [--size ] +// is one of: twin, sync, folder-pair, folder-icon, wordmark, tui-banner, all (default) + +import AppKit +import CoreGraphics +import Foundation + +// MARK: - Palette + +private func sRGB(_ c: NSColor) -> NSColor { + c.usingColorSpace(.sRGB) ?? c +} + +struct Palette { + static let blue = sRGB(.systemBlue) // Finder badge: Has Local Config / Org / User + static let green = sRGB(.systemGreen) // Finder badge: Synced + static let orange = sRGB(.systemOrange) // Finder badge: Partially Synced + static let red = sRGB(.systemRed) // Finder badge: Uncommitted Changes + static let gray = sRGB(.systemGray) // Finder badge: Git Repository + static let cream = NSColor(srgbRed: 0xF5/255.0, green: 0xF1/255.0, blue: 0xE8/255.0, alpha: 1) + static let ink = NSColor(srgbRed: 0x0B/255.0, green: 0x1B/255.0, blue: 0x2A/255.0, alpha: 1) +} + +let allVariants = ["twin", "sync", "folder-pair", "folder-icon", "wordmark", "tui-banner"] + +// MARK: - Args + +struct Args { + var variant: String = "all" + var out: String = "crates/git-same-app/icons/variants" + var size: Int = 1024 +} + +func parseArgs() -> Args { + var a = Args() + var it = CommandLine.arguments.dropFirst().makeIterator() + while let arg = it.next() { + switch arg { + case "--variant", "-v": + if let v = it.next() { a.variant = v } + case "--out", "-o": + if let v = it.next() { a.out = v } + case "--size", "-s": + if let v = it.next(), let n = Int(v) { a.size = n } + case "-h", "--help": + FileHandle.standardError.write(Data(""" + generate-icons.swift [--variant ] [--out ] [--size ] + variants: \(allVariants.joined(separator: ", ")), all + """.utf8)) + exit(0) + default: + FileHandle.standardError.write(Data("unknown arg: \(arg)\n".utf8)) + exit(2) + } + } + return a +} + +// MARK: - Rendering helpers + +func makeContext(size: Int) -> CGContext { + let cs = CGColorSpaceCreateDeviceRGB() + let ctx = CGContext(data: nil, + width: size, height: size, + bitsPerComponent: 8, + bytesPerRow: size * 4, + space: cs, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + ctx.interpolationQuality = .high + ctx.setShouldAntialias(true) + return ctx +} + +func savePNG(_ ctx: CGContext, to path: String) throws { + guard let img = ctx.makeImage() else { + throw NSError(domain: "icon", code: 1, userInfo: [NSLocalizedDescriptionKey: "ctx.makeImage failed"]) + } + let rep = NSBitmapImageRep(cgImage: img) + guard let data = rep.representation(using: .png, properties: [:]) else { + throw NSError(domain: "icon", code: 2, userInfo: [NSLocalizedDescriptionKey: "PNG encode failed"]) + } + let url = URL(fileURLWithPath: path) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url) +} + +// Continuous-curvature squircle approximation: rounded rect with r ≈ 0.2237 * side. +// Good enough at 1024px to read as a macOS app icon. +func squirclePath(in rect: CGRect) -> CGPath { + let r = min(rect.width, rect.height) * 0.2237 + return CGPath(roundedRect: rect, cornerWidth: r, cornerHeight: r, transform: nil) +} + +func drawGradientBackground(_ ctx: CGContext, size: CGFloat) { + let rect = CGRect(x: 0, y: 0, width: size, height: size) + ctx.saveGState() + ctx.addPath(squirclePath(in: rect)) + ctx.clip() + + let cs = CGColorSpaceCreateDeviceRGB() + let colors: [CGColor] = [Palette.blue.cgColor, Palette.green.cgColor] + let stops: [CGFloat] = [0.0, 1.0] + let grad = CGGradient(colorsSpace: cs, colors: colors as CFArray, locations: stops)! + // Diagonal: top-left → bottom-right. + ctx.drawLinearGradient(grad, + start: CGPoint(x: 0, y: size), + end: CGPoint(x: size, y: 0), + options: []) + ctx.restoreGState() +} + +func drawAttributedText(_ ctx: CGContext, _ text: String, attrs: [NSAttributedString.Key: Any], at point: CGPoint) { + let attr = NSAttributedString(string: text, attributes: attrs) + let line = CTLineCreateWithAttributedString(attr) + ctx.textPosition = point + CTLineDraw(line, ctx) +} + +func textBounds(_ text: String, attrs: [NSAttributedString.Key: Any]) -> CGRect { + let attr = NSAttributedString(string: text, attributes: attrs) + let line = CTLineCreateWithAttributedString(attr) + return CTLineGetBoundsWithOptions(line, .useOpticalBounds) +} + +// MARK: - Variant 1: twin (liquid glass) + +func drawTwin(_ ctx: CGContext, size: CGFloat) { + drawGradientBackground(ctx, size: size) + + // Two overlapping rounded-square "repo tiles", rendered as Liquid Glass: + // translucent panels with a top specular highlight, soft drop shadow, + // hairline rim stroke, and a subtle tint hinting at the badge color + // (green for the back/"synced" tile, blue for the front/"local config" tile). + let tileSide = size * 0.50 + let r = tileSide * 0.26 + let cx = size / 2 + let cy = size / 2 + let off = size * 0.075 + + let backRect = CGRect(x: cx - tileSide/2 - off, + y: cy - tileSide/2 + off, + width: tileSide, height: tileSide) + let frontRect = CGRect(x: cx - tileSide/2 + off, + y: cy - tileSide/2 - off, + width: tileSide, height: tileSide) + + drawGlassTile(ctx, rect: backRect, cornerRadius: r, + tint: Palette.green, rowColor: Palette.green, size: size, + excludeRect: frontRect, excludeCornerRadius: r) + drawGlassTile(ctx, rect: frontRect, cornerRadius: r, + tint: Palette.blue, rowColor: Palette.blue, size: size, + excludeRect: nil, excludeCornerRadius: 0) +} + +/// Renders one Liquid Glass tile with three list rows inside. +/// If `excludeRect` is provided, the tile's row content is clipped to the +/// area outside that rect — used so the back tile's rows don't bleed +/// through the front tile. +func drawGlassTile(_ ctx: CGContext, rect: CGRect, cornerRadius r: CGFloat, + tint: NSColor, rowColor: NSColor, size: CGFloat, + excludeRect: CGRect?, excludeCornerRadius: CGFloat) { + let path = CGPath(roundedRect: rect, cornerWidth: r, cornerHeight: r, transform: nil) + let cs = CGColorSpaceCreateDeviceRGB() + + // 1. Soft drop shadow under the panel. + ctx.saveGState() + ctx.setShadow(offset: CGSize(width: 0, height: -size * 0.012), + blur: size * 0.035, + color: NSColor.black.withAlphaComponent(0.28).cgColor) + ctx.setFillColor(NSColor.black.withAlphaComponent(0.001).cgColor) + ctx.addPath(path) + ctx.fillPath() + ctx.restoreGState() + + // 2. Glass body: translucent white with a hint of tint, clipped to the panel. + ctx.saveGState() + ctx.addPath(path) + ctx.clip() + + // 2a. Base translucent fill. + ctx.setFillColor(NSColor.white.withAlphaComponent(0.32).cgColor) + ctx.fill(rect) + + // 2b. Vertical glass gradient: brighter at top, slightly cooler at bottom. + let bodyGrad = CGGradient(colorsSpace: cs, colors: [ + NSColor.white.withAlphaComponent(0.55).cgColor, + NSColor.white.withAlphaComponent(0.10).cgColor, + ] as CFArray, locations: [0.0, 1.0])! + ctx.drawLinearGradient(bodyGrad, + start: CGPoint(x: rect.midX, y: rect.maxY), + end: CGPoint(x: rect.midX, y: rect.minY), + options: []) + + // 2c. Subtle tint band hugging the bottom edge, so the back tile reads + // green and the front tile reads blue without going opaque. + let tintGrad = CGGradient(colorsSpace: cs, colors: [ + tint.withAlphaComponent(0.0).cgColor, + tint.withAlphaComponent(0.22).cgColor, + ] as CFArray, locations: [0.0, 1.0])! + ctx.drawLinearGradient(tintGrad, + start: CGPoint(x: rect.midX, y: rect.maxY), + end: CGPoint(x: rect.midX, y: rect.minY), + options: []) + + // 2d. Top specular highlight: thin bright band along the upper edge. + let specHeight = rect.height * 0.18 + let specRect = CGRect(x: rect.minX, y: rect.maxY - specHeight, + width: rect.width, height: specHeight) + let specGrad = CGGradient(colorsSpace: cs, colors: [ + NSColor.white.withAlphaComponent(0.65).cgColor, + NSColor.white.withAlphaComponent(0.0).cgColor, + ] as CFArray, locations: [0.0, 1.0])! + ctx.drawLinearGradient(specGrad, + start: CGPoint(x: specRect.midX, y: specRect.maxY), + end: CGPoint(x: specRect.midX, y: specRect.minY), + options: []) + + // 2e. Rows on top — solid for contrast against the glass. + // If an exclude rect is provided, clip rows to (tileRect MINUS excludeRect) + // using even-odd fill so we don't paint rows beneath the front tile. + if let ex = excludeRect { + ctx.saveGState() + let outer = CGPath(rect: rect, transform: nil) + let inner = CGPath(roundedRect: ex, + cornerWidth: excludeCornerRadius, + cornerHeight: excludeCornerRadius, + transform: nil) + let combined = CGMutablePath() + combined.addPath(outer) + combined.addPath(inner) + ctx.addPath(combined) + ctx.clip(using: .evenOdd) + drawRepoRows(ctx, in: rect, color: rowColor) + ctx.restoreGState() + } else { + drawRepoRows(ctx, in: rect, color: rowColor) + } + ctx.restoreGState() + + // 3. Hairline rim stroke for definition. + ctx.saveGState() + ctx.addPath(path) + ctx.setStrokeColor(NSColor.white.withAlphaComponent(0.55).cgColor) + ctx.setLineWidth(size * 0.004) + ctx.strokePath() + ctx.restoreGState() +} + +func drawRepoRows(_ ctx: CGContext, in rect: CGRect, color: NSColor) { + let rows = 3 + let pad = rect.width * 0.16 + let inner = rect.insetBy(dx: pad, dy: pad) + let rowHeight = inner.height * 0.18 + let gap = (inner.height - CGFloat(rows) * rowHeight) / CGFloat(rows - 1) * 0.6 + let totalH = CGFloat(rows) * rowHeight + CGFloat(rows - 1) * gap + let startY = inner.midY + totalH/2 - rowHeight + + ctx.saveGState() + ctx.setFillColor(color.cgColor) + for i in 0.. CGFloat { d * .pi / 180 } + +func drawArrowHead(_ ctx: CGContext, center: CGPoint, radius: CGFloat, + angle: CGFloat, tangentClockwise: Bool, size s: CGFloat, color: NSColor) { + let tip = CGPoint(x: center.x + radius * cos(angle), y: center.y + radius * sin(angle)) + // Tangent direction at the arc endpoint. + let tAngle = angle + (tangentClockwise ? -.pi/2 : .pi/2) + let back = CGPoint(x: tip.x - cos(tAngle) * s * 1.8, + y: tip.y - sin(tAngle) * s * 1.8) + let perp = CGPoint(x: cos(tAngle + .pi/2) * s, y: sin(tAngle + .pi/2) * s) + let p1 = CGPoint(x: back.x + perp.x, y: back.y + perp.y) + let p2 = CGPoint(x: back.x - perp.x, y: back.y - perp.y) + ctx.saveGState() + ctx.setFillColor(color.cgColor) + ctx.beginPath() + ctx.move(to: tip) + ctx.addLine(to: p1) + ctx.addLine(to: p2) + ctx.closePath() + ctx.fillPath() + ctx.restoreGState() +} + +// MARK: - Variant 3: folder-pair + +func drawFolderPair(_ ctx: CGContext, size: CGFloat) { + drawGradientBackground(ctx, size: size) + + let w = size * 0.62 + let h = size * 0.50 + let cx = size / 2 + let cy = size / 2 + + // Back folder, offset up-left, slightly smaller, tinted darker green. + let backRect = CGRect(x: cx - w/2 - size * 0.06, + y: cy - h/2 + size * 0.08, + width: w * 0.92, height: h * 0.92) + drawFolder(ctx, rect: backRect, body: Palette.green, tab: Palette.green.blended(withFraction: 0.25, of: .black) ?? Palette.green) + + // Front folder, full size, cream. + let frontRect = CGRect(x: cx - w/2 + size * 0.04, + y: cy - h/2 - size * 0.04, + width: w, height: h) + drawFolder(ctx, rect: frontRect, body: Palette.cream, tab: Palette.blue) + + // Small arrow from back-folder corner to front folder, suggesting "remote → local". + let from = CGPoint(x: backRect.midX, y: backRect.maxY - h * 0.10) + let to = CGPoint(x: frontRect.minX + w * 0.18, y: frontRect.maxY - h * 0.18) + ctx.saveGState() + ctx.setStrokeColor(Palette.cream.cgColor) + ctx.setLineWidth(size * 0.022) + ctx.setLineCap(.round) + ctx.beginPath() + ctx.move(to: from) + ctx.addLine(to: to) + ctx.strokePath() + // Arrowhead at `to`. + let ang = atan2(to.y - from.y, to.x - from.x) + let hs = size * 0.035 + let leftP = CGPoint(x: to.x - cos(ang - .pi/6) * hs, y: to.y - sin(ang - .pi/6) * hs) + let rightP = CGPoint(x: to.x - cos(ang + .pi/6) * hs, y: to.y - sin(ang + .pi/6) * hs) + ctx.setFillColor(Palette.cream.cgColor) + ctx.beginPath() + ctx.move(to: to) + ctx.addLine(to: leftP) + ctx.addLine(to: rightP) + ctx.closePath() + ctx.fillPath() + ctx.restoreGState() +} + +func drawFolder(_ ctx: CGContext, rect: CGRect, body: NSColor, tab: NSColor) { + let r = rect.height * 0.10 + // Tab on top. + let tabRect = CGRect(x: rect.minX, + y: rect.maxY - rect.height * 0.18, + width: rect.width * 0.42, + height: rect.height * 0.16) + ctx.saveGState() + ctx.setFillColor(tab.cgColor) + ctx.addPath(CGPath(roundedRect: tabRect, cornerWidth: r * 0.6, cornerHeight: r * 0.6, transform: nil)) + ctx.fillPath() + + // Folder body. + let bodyRect = CGRect(x: rect.minX, y: rect.minY, + width: rect.width, + height: rect.height * 0.88) + ctx.setFillColor(body.cgColor) + ctx.addPath(CGPath(roundedRect: bodyRect, cornerWidth: r, cornerHeight: r, transform: nil)) + ctx.fillPath() + ctx.restoreGState() +} + +// MARK: - Variant 3b: folder-icon (workspace folder, Synology-style) +// +// Painted onto the workspace root directory via NSWorkspace.setIcon so Finder +// shows it in sidebar / column / list / icon views and the Get Info preview. +// Unlike the app-icon variants, this renders on a TRANSPARENT canvas: Finder +// expects a folder-shaped silhouette, not a squircle tile. +// +// Composition: +// - A single macOS-blue folder shape filling most of the canvas. +// - The twin-tiles glyph (two overlapping rounded squares, the Git-Same mark) +// composited onto the front face of the folder, scaled to read at 32px. + +func drawWorkspaceFolderIcon(_ ctx: CGContext, size: CGFloat) { + // 1. Folder silhouette. Centered, with breathing room top/bottom so the + // tab doesn't kiss the canvas edge. + let folderW = size * 0.86 + let folderH = size * 0.66 + let folderRect = CGRect(x: (size - folderW) / 2, + y: (size - folderH) / 2 - size * 0.02, + width: folderW, height: folderH) + + // Subtle drop shadow so the folder reads as an object on the desktop. + ctx.saveGState() + ctx.setShadow(offset: CGSize(width: 0, height: -size * 0.012), + blur: size * 0.030, + color: NSColor.black.withAlphaComponent(0.30).cgColor) + ctx.setFillColor(NSColor.black.withAlphaComponent(0.001).cgColor) + ctx.fill(folderRect) + ctx.restoreGState() + + // The tab + body, in macOS folder blue. + let folderBlue = NSColor(srgbRed: 0x4A/255.0, green: 0x90/255.0, blue: 0xD9/255.0, alpha: 1) + let folderBlueDark = folderBlue.blended(withFraction: 0.20, of: .black) ?? folderBlue + drawFolder(ctx, rect: folderRect, body: folderBlue, tab: folderBlueDark) + + // 2. Twin-tiles glyph on the folder face. Smaller and centered horizontally, + // biased toward the bottom of the folder body so it reads as "on the + // front face" rather than crowding the tab. + let bodyRect = CGRect(x: folderRect.minX, y: folderRect.minY, + width: folderRect.width, + height: folderRect.height * 0.88) + let glyphScale: CGFloat = 0.62 + let glyphSide = bodyRect.height * glyphScale + let glyphCX = bodyRect.midX + let glyphCY = bodyRect.midY - bodyRect.height * 0.04 + let off = glyphSide * 0.15 + let tileSide = glyphSide * 0.78 + let r = tileSide * 0.26 + + let backRect = CGRect(x: glyphCX - tileSide/2 - off, + y: glyphCY - tileSide/2 + off, + width: tileSide, height: tileSide) + let frontRect = CGRect(x: glyphCX - tileSide/2 + off, + y: glyphCY - tileSide/2 - off, + width: tileSide, height: tileSide) + + // For folder-icon use we want the tiles to read clearly against the blue + // folder body, so we use opaque cream + green/blue tints rather than the + // translucent Liquid Glass treatment used by the app icon. + drawSolidTile(ctx, rect: backRect, cornerRadius: r, + body: Palette.green, accent: Palette.green.blended(withFraction: 0.35, of: .black) ?? Palette.green, + excludeRect: frontRect, excludeCornerRadius: r) + drawSolidTile(ctx, rect: frontRect, cornerRadius: r, + body: Palette.cream, accent: Palette.blue, + excludeRect: nil, excludeCornerRadius: 0) +} + +/// Opaque variant of `drawGlassTile` used by the folder-icon variant. Reads +/// better against the saturated blue folder body than the translucent app-icon +/// glass. +func drawSolidTile(_ ctx: CGContext, rect: CGRect, cornerRadius r: CGFloat, + body: NSColor, accent: NSColor, + excludeRect: CGRect?, excludeCornerRadius: CGFloat) { + let path = CGPath(roundedRect: rect, cornerWidth: r, cornerHeight: r, transform: nil) + + // Body fill. + ctx.saveGState() + ctx.addPath(path) + ctx.clip() + ctx.setFillColor(body.cgColor) + ctx.fill(rect) + + // Repo rows, clipped away from the front tile when relevant. + if let ex = excludeRect { + ctx.saveGState() + let outer = CGPath(rect: rect, transform: nil) + let inner = CGPath(roundedRect: ex, + cornerWidth: excludeCornerRadius, + cornerHeight: excludeCornerRadius, + transform: nil) + let combined = CGMutablePath() + combined.addPath(outer) + combined.addPath(inner) + ctx.addPath(combined) + ctx.clip(using: .evenOdd) + drawRepoRows(ctx, in: rect, color: accent) + ctx.restoreGState() + } else { + drawRepoRows(ctx, in: rect, color: accent) + } + ctx.restoreGState() + + // Hairline rim so adjacent tiles separate cleanly. + ctx.saveGState() + ctx.addPath(path) + ctx.setStrokeColor(NSColor.white.withAlphaComponent(0.40).cgColor) + ctx.setLineWidth(max(1, rect.width * 0.014)) + ctx.strokePath() + ctx.restoreGState() +} + +// MARK: - Variant 4: wordmark + +func drawWordmark(_ ctx: CGContext, size: CGFloat) { + drawGradientBackground(ctx, size: size) + + // Big monogram "gs" in cream, with an equals-style mirror cue between the letters. + let fontSize = size * 0.58 + let font = NSFont.systemFont(ofSize: fontSize, weight: .black) + let attrs: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: Palette.cream, + ] + let text = "gs" + let bounds = textBounds(text, attrs: attrs) + let pos = CGPoint(x: size/2 - bounds.width/2 - bounds.minX, + y: size/2 - bounds.height/2 - bounds.minY) + drawAttributedText(ctx, text, attrs: attrs, at: pos) + + // Two short horizontal bars to the right, suggesting "=" (same). + let barW = size * 0.16 + let barH = size * 0.045 + let barX = size * 0.78 - barW/2 + let barGap = size * 0.04 + ctx.saveGState() + ctx.setFillColor(Palette.cream.cgColor) + for y in [size/2 + barGap/2, size/2 - barGap/2 - barH] { + let r = barH / 2 + ctx.addPath(CGPath(roundedRect: CGRect(x: barX, y: y, width: barW, height: barH), + cornerWidth: r, cornerHeight: r, transform: nil)) + ctx.fillPath() + } + ctx.restoreGState() +} + +// MARK: - Variant 5: tui-banner + +func drawTuiBanner(_ ctx: CGContext, size: CGFloat) { + drawGradientBackground(ctx, size: size) + + // "g=s" rendered in a bold monospace, each glyph colored from the gradient stops. + // Reads as a wink to the TUI ASCII banner. + let fontSize = size * 0.46 + let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .heavy) + + let glyphs: [(String, NSColor)] = [ + ("g", Palette.cream), + ("=", Palette.cream), + ("s", Palette.cream), + ] + + // Measure full string to center. + let fullAttrs: [NSAttributedString.Key: Any] = [ + .font: font, .foregroundColor: Palette.cream, + ] + let fullText = glyphs.map { $0.0 }.joined() + let fullBounds = textBounds(fullText, attrs: fullAttrs) + var x = size/2 - fullBounds.width/2 - fullBounds.minX + let y = size/2 - fullBounds.height/2 - fullBounds.minY + + for (s, color) in glyphs { + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: color] + drawAttributedText(ctx, s, attrs: attrs, at: CGPoint(x: x, y: y)) + let b = textBounds(s, attrs: attrs) + x += b.width + } + + // Top and bottom hairline rules in cream, evoking the TUI banner's double-line frame. + ctx.saveGState() + ctx.setStrokeColor(Palette.cream.withAlphaComponent(0.85).cgColor) + ctx.setLineWidth(size * 0.012) + let inset = size * 0.16 + let topY = size * 0.78 + let botY = size * 0.22 + for ly in [topY, topY - size * 0.025, botY, botY + size * 0.025] { + ctx.beginPath() + ctx.move(to: CGPoint(x: inset, y: ly)) + ctx.addLine(to: CGPoint(x: size - inset, y: ly)) + ctx.strokePath() + } + ctx.restoreGState() +} + +// MARK: - Dispatch + +func renderVariant(_ name: String, size: Int) -> CGContext? { + let s = CGFloat(size) + let ctx = makeContext(size: size) + + // Default-clear background (transparent) so the squircle alpha shows. + ctx.clear(CGRect(x: 0, y: 0, width: s, height: s)) + + // Push an NSGraphicsContext so AppKit/CoreText drawing works. + let nsCtx = NSGraphicsContext(cgContext: ctx, flipped: false) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = nsCtx + defer { NSGraphicsContext.restoreGraphicsState() } + + switch name { + case "twin": drawTwin(ctx, size: s) + case "sync": drawSync(ctx, size: s) + case "folder-pair": drawFolderPair(ctx, size: s) + case "folder-icon": drawWorkspaceFolderIcon(ctx, size: s) + case "wordmark": drawWordmark(ctx, size: s) + case "tui-banner": drawTuiBanner(ctx, size: s) + default: + FileHandle.standardError.write(Data("unknown variant: \(name)\n".utf8)) + return nil + } + return ctx +} + +// MARK: - Main + +let args = parseArgs() +let variants = (args.variant == "all") ? allVariants : [args.variant] + +for v in variants { + guard let ctx = renderVariant(v, size: args.size) else { exit(2) } + let path = "\(args.out)/\(v).png" + do { + try savePNG(ctx, to: path) + print("wrote \(path)") + } catch { + FileHandle.standardError.write(Data("failed to write \(path): \(error)\n".utf8)) + exit(1) + } +} diff --git a/toolkit/icons/promote.sh b/toolkit/icons/promote.sh new file mode 100755 index 0000000..26fe278 --- /dev/null +++ b/toolkit/icons/promote.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Promote a variant from toolkit/icons/generate-icons.swift into the live +# crates/git-same-app/icons/ asset set: regenerates 1024x1024 master, +# downsamples all PNG sizes, rebuilds icon.icns, and refreshes icon.ico. +# +# Usage: bash toolkit/icons/promote.sh +# : twin | sync | folder-pair | wordmark | tui-banner + +set -euo pipefail + +VARIANT="${1:-}" +if [ -z "$VARIANT" ]; then + echo "usage: $0 " >&2 + echo " variants: twin sync folder-pair wordmark tui-banner" >&2 + exit 2 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ICONS="$ROOT/crates/git-same-app/icons" +GEN="$ROOT/toolkit/icons/generate-icons.swift" + +if [ ! -f "$GEN" ]; then + echo "missing generator at $GEN" >&2 + exit 1 +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# 1. Render the chosen variant at 1024. +swift "$GEN" --variant "$VARIANT" --out "$TMP" --size 1024 >/dev/null +MASTER="$TMP/$VARIANT.png" +if [ ! -f "$MASTER" ]; then + echo "generator did not produce $MASTER" >&2 + exit 1 +fi + +# 2. Replace master + the sizes referenced by tauri.conf.json. +cp "$MASTER" "$ICONS/1024x1024.png" +cp "$MASTER" "$ICONS/icon.png" + +# Only the sizes referenced by crates/git-same-app/tauri.conf.json. +# 64x64 is gitignored Tauri-CLI output we don't ship, but we regenerate it +# here so the icons/ directory matches what `pnpm tauri icon` would produce. +for size in 32 64 128; do + out="$ICONS/${size}x${size}.png" + cp "$MASTER" "$out" + sips -Z "$size" "$out" >/dev/null +done +cp "$MASTER" "$ICONS/128x128@2x.png" +sips -Z 256 "$ICONS/128x128@2x.png" >/dev/null + +# 3. Assemble macOS .icns from an iconset. +ISET="$TMP/AppIcon.iconset" +mkdir -p "$ISET" +declare -a entries=( + "16:icon_16x16.png" + "32:icon_16x16@2x.png" + "32:icon_32x32.png" + "64:icon_32x32@2x.png" + "128:icon_128x128.png" + "256:icon_128x128@2x.png" + "256:icon_256x256.png" + "512:icon_256x256@2x.png" + "512:icon_512x512.png" + "1024:icon_512x512@2x.png" +) +for entry in "${entries[@]}"; do + size="${entry%%:*}" + name="${entry##*:}" + cp "$MASTER" "$ISET/$name" + sips -Z "$size" "$ISET/$name" >/dev/null +done +iconutil -c icns -o "$ICONS/icon.icns" "$ISET" + +# 4. Regenerate icon.ico from the 256 PNG via sips. +ICO_SRC="$TMP/ico-source.png" +cp "$MASTER" "$ICO_SRC" +sips -Z 256 "$ICO_SRC" >/dev/null +sips -s format ico "$ICO_SRC" --out "$ICONS/icon.ico" >/dev/null + +echo "promoted '$VARIANT' to:" +echo " $ICONS/1024x1024.png" +echo " $ICONS/{32,64,128}x{32,64,128}.png" +echo " $ICONS/128x128@2x.png" +echo " $ICONS/icon.png" +echo " $ICONS/icon.icns" +echo " $ICONS/icon.ico" diff --git a/toolkit/packaging/gen-completions.sh b/toolkit/packaging/gen-completions.sh index 91df448..c412cb6 100755 --- a/toolkit/packaging/gen-completions.sh +++ b/toolkit/packaging/gen-completions.sh @@ -35,6 +35,7 @@ for entry in "${SHELLS[@]}"; do echo "==> $SHELL_NAME -> $OUT_PATH" cargo run \ --release \ + -p git-same \ --features release-tools \ --bin gen-completions \ -- "$SHELL_NAME" \ diff --git a/toolkit/packaging/gen-manpage.sh b/toolkit/packaging/gen-manpage.sh index 9c4604b..3acf655 100755 --- a/toolkit/packaging/gen-manpage.sh +++ b/toolkit/packaging/gen-manpage.sh @@ -24,6 +24,7 @@ OUT_PATH="$OUT_DIR/git-same.1" echo "==> manpage -> $OUT_PATH" cargo run \ --release \ + -p git-same \ --features release-tools \ --bin gen-manpage \ > "$OUT_PATH" diff --git a/toolkit/packaging/macos/build-app-bundle.sh b/toolkit/packaging/macos/build-app-bundle.sh new file mode 100755 index 0000000..c68d061 --- /dev/null +++ b/toolkit/packaging/macos/build-app-bundle.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# Build the git-same macOS app bundle and DMG. + +set -euo pipefail + +ROOT="${WORKSPACE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}" +VERSION="${VERSION:-}" +ARCH="${ARCH:-}" +OUTPUT_DIR="${OUTPUT_DIR:-$ROOT/dist/macos}" +INCLUDE_FINDER_EXTENSION="${INCLUDE_FINDER_EXTENSION:-0}" +SKIP_SIGNING="${SKIP_SIGNING:-0}" +SKIP_NOTARIZATION="${SKIP_NOTARIZATION:-0}" + +usage() { + cat <&2 +Required env vars: + VERSION Strict semver, e.g. 3.1.0 + ARCH aarch64 or x86_64 + +Optional env vars: + WORKSPACE_ROOT Repo root (default: auto-detected) + OUTPUT_DIR Artifact output directory (default: dist/macos) + INCLUDE_FINDER_EXTENSION 1 to embed GitSameBadges.appex, 0 for D-App + SKIP_SIGNING 1 to build unsigned app/dmg for local smoke tests + SKIP_NOTARIZATION 1 to sign without notarytool/stapler +EOF +} + +if [ -z "$VERSION" ] || [ -z "$ARCH" ]; then + usage + exit 2 +fi +if ! [[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "ERROR: VERSION must be strict semver, got '$VERSION'" >&2 + exit 2 +fi +case "$ARCH" in + aarch64|x86_64) ;; + *) echo "ERROR: ARCH must be aarch64 or x86_64, got '$ARCH'" >&2; exit 2 ;; +esac + +TARGET="${ARCH}-apple-darwin" +BUILD_ROOT="$OUTPUT_DIR/build-${ARCH}" +APP="$OUTPUT_DIR/Git-Same.app" +DMG="$OUTPUT_DIR/git-same-${VERSION}-${ARCH}.dmg" +SIGN_SCRIPT="$ROOT/toolkit/packaging/macos/sign-app-bundle.sh" + +mkdir -p "$OUTPUT_DIR" +rm -rf "$BUILD_ROOT" "$APP" "$DMG" +mkdir -p "$BUILD_ROOT" + +echo "==> Building CLI ($TARGET)" +( cd "$ROOT" && cargo build --release --target "$TARGET" -p git-same ) + +echo "==> Installing frontend dependencies" +if command -v corepack >/dev/null 2>&1; then + corepack enable pnpm +fi +PNPM=(pnpm) +if ! command -v pnpm >/dev/null 2>&1; then + PNPM=(corepack pnpm) +fi +( cd "$ROOT/crates/git-same-app" && "${PNPM[@]}" --dir ui install --frozen-lockfile ) + +echo "==> Building Tauri app binary ($TARGET, no bundle)" +TAURI_CLI="$ROOT/crates/git-same-app/ui/node_modules/.bin/tauri" +if [ ! -x "$TAURI_CLI" ]; then + echo "ERROR: Tauri CLI not found at $TAURI_CLI" >&2 + exit 1 +fi +( cd "$ROOT/crates/git-same-app" && "$TAURI_CLI" build --target "$TARGET" --no-bundle ) + +echo "==> Assembling app bundle" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Helpers" "$APP/Contents/Resources" "$APP/Contents/PlugIns" +cp "$ROOT/target/$TARGET/release/git-same-app" "$APP/Contents/MacOS/git-same-app" +cp "$ROOT/target/$TARGET/release/git-same" "$APP/Contents/Helpers/git-same" +cp "$ROOT/macos/com.zaai.git-same.monitor.plist" "$APP/Contents/Resources/com.zaai.git-same.monitor.plist" +cp "$ROOT/crates/git-same-app/icons/icon.icns" "$APP/Contents/Resources/icons.icns" +chmod +x "$APP/Contents/MacOS/git-same-app" "$APP/Contents/Helpers/git-same" + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleExecutablegit-same-app + CFBundleIconFileicons.icns + CFBundleIdentifiercom.zaai.git-same + CFBundleNameGit-Same + CFBundleDisplayNameGit-Same + CFBundleVersion${VERSION} + CFBundleShortVersionString${VERSION} + CFBundlePackageTypeAPPL + LSMinimumSystemVersion13.0 + LSApplicationCategoryTypepublic.app-category.developer-tools + NSHighResolutionCapable + + +EOF + +if [ "$INCLUDE_FINDER_EXTENSION" = "1" ]; then + echo "==> Building FinderSync extension" + xcodebuild \ + -project "$ROOT/macos/GitSameBadges.xcodeproj" \ + -scheme GitSameBadges \ + -configuration Release \ + -destination "generic/platform=macOS" \ + SYMROOT="$BUILD_ROOT/xcode-products" \ + OBJROOT="$BUILD_ROOT/xcode-obj" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + build + + APPEX="$BUILD_ROOT/xcode-products/Release/GitSameBadges.appex" + if [ ! -d "$APPEX" ]; then + echo "ERROR: FinderSync extension product not found at $APPEX" >&2 + exit 1 + fi + cp -R "$APPEX" "$APP/Contents/PlugIns/" +fi + +if [ "$SKIP_SIGNING" != "1" ]; then + SIGN_ARGS=() + if [ "$SKIP_NOTARIZATION" = "1" ]; then + SIGN_ARGS+=(--skip-notarization) + fi + # `${arr[@]+"${arr[@]}"}` expands to nothing when the array is empty + # instead of tripping `set -u` on macOS's stock bash 3.2 (where a bare + # `"${arr[@]}"` on an empty array errors with "unbound variable"). + bash "$SIGN_SCRIPT" "$APP" ${SIGN_ARGS[@]+"${SIGN_ARGS[@]}"} +fi + +echo "==> Creating DMG" +if command -v create-dmg >/dev/null 2>&1; then + create-dmg \ + --volname "Git-Same ${VERSION}" \ + --window-size 540 380 \ + --icon-size 100 \ + --icon "Git-Same.app" 140 190 \ + --app-drop-link 400 190 \ + "$DMG" \ + "$APP" +else + DMG_ROOT="$BUILD_ROOT/dmg-root" + mkdir -p "$DMG_ROOT" + cp -R "$APP" "$DMG_ROOT/" + ln -s /Applications "$DMG_ROOT/Applications" + hdiutil create -volname "Git-Same ${VERSION}" -srcfolder "$DMG_ROOT" -ov -format UDZO "$DMG" +fi + +if [ "$SKIP_SIGNING" != "1" ]; then + SIGN_ARGS=(--skip-app --dmg "$DMG") + if [ "$SKIP_NOTARIZATION" = "1" ]; then + SIGN_ARGS+=(--skip-notarization) + fi + bash "$SIGN_SCRIPT" "$APP" ${SIGN_ARGS[@]+"${SIGN_ARGS[@]}"} +fi + +shasum -a 256 "$DMG" | awk '{print $1}' > "$DMG.sha256" + +echo "==> Done" +echo " app: $APP" +echo " dmg: $DMG" +echo " sha256: $(cat "$DMG.sha256")" diff --git a/toolkit/packaging/macos/check-entitlements-parity.sh b/toolkit/packaging/macos/check-entitlements-parity.sh new file mode 100755 index 0000000..56ea397 --- /dev/null +++ b/toolkit/packaging/macos/check-entitlements-parity.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Verify that the Tauri host and the FinderSync extension declare matching +# `com.apple.security.application-groups` entries. +# +# A typo here silently splits the runtime container: the monitor writes to +# one group and the extension reads from another, and badges stop rendering +# without an obvious error. CI must catch this before signing. +# +# macOS-only (uses /usr/bin/plutil). Run from S5 before the bundle build, +# and from S1's macOS Tauri build job so PRs catch drift early. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +HOST="$ROOT/crates/git-same-app/entitlements.plist" +EXT="$ROOT/macos/GitSameBadges/GitSameBadges.entitlements" + +for f in "$HOST" "$EXT"; do + if [ ! -r "$f" ]; then + echo "ERROR: entitlements file not readable: $f" >&2 + exit 1 + fi +done + +extract_groups() { + # PlistBuddy treats `:` as path separators (not `.`), so dotted keys like + # `com.apple.security.application-groups` work as a single segment. + # `-x` emits XML; we pull the inner elements and sort them so + # the comparison is order-insensitive. + /usr/libexec/PlistBuddy -x \ + -c "Print :com.apple.security.application-groups" "$1" \ + | grep -oE '[^<]*' \ + | sed 's///' \ + | sort +} + +HOST_GROUPS="$(extract_groups "$HOST")" +EXT_GROUPS="$(extract_groups "$EXT")" + +if [ "$HOST_GROUPS" != "$EXT_GROUPS" ]; then + echo "ERROR: application-groups mismatch between host and extension." >&2 + echo " host ($HOST):" >&2 + echo " $HOST_GROUPS" >&2 + echo " ext ($EXT):" >&2 + echo " $EXT_GROUPS" >&2 + echo "" >&2 + echo "These lists must be identical. A mismatch silently splits the" >&2 + echo "runtime app-group container and breaks Finder badge rendering." >&2 + exit 1 +fi + +echo "OK: application-groups match across host and extension" +echo " $HOST_GROUPS" diff --git a/toolkit/packaging/macos/sign-app-bundle.sh b/toolkit/packaging/macos/sign-app-bundle.sh new file mode 100755 index 0000000..dd721cd --- /dev/null +++ b/toolkit/packaging/macos/sign-app-bundle.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# Sign, verify, notarize, and staple a git-same macOS app bundle and, +# optionally, its DMG. +# +# Usage: +# sign-app-bundle.sh APP_PATH [--dmg DMG_PATH] [--skip-app] [--skip-notarization] + +set -euo pipefail + +APP_PATH="" +DMG_PATH="" +SKIP_APP=0 +SKIP_NOTARIZATION=0 + +usage() { + cat <&2 +Usage: $0 APP_PATH [--dmg DMG_PATH] [--skip-app] [--skip-notarization] + +Required env vars: + APPLE_DEVELOPER_CERTIFICATE_P12 + APPLE_DEVELOPER_CERTIFICATE_PASSWORD + APPLE_SIGNING_IDENTITY + APPLE_ID + APPLE_TEAM_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_KEYCHAIN_PASSWORD +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --dmg) DMG_PATH="${2:-}"; shift 2 ;; + --skip-app) SKIP_APP=1; shift ;; + --skip-notarization) SKIP_NOTARIZATION=1; shift ;; + -h|--help) usage; exit 0 ;; + --*) echo "ERROR: unknown flag $1" >&2; usage; exit 2 ;; + *) + if [ -z "$APP_PATH" ]; then + APP_PATH="$1"; shift + else + echo "ERROR: unexpected positional arg $1" >&2; usage; exit 2 + fi + ;; + esac +done + +if [ -z "$APP_PATH" ]; then + echo "ERROR: APP_PATH is required" >&2; usage; exit 2 +fi +if [ ! -d "$APP_PATH" ]; then + echo "ERROR: app bundle not found: $APP_PATH" >&2; exit 1 +fi +if [ -n "$DMG_PATH" ] && [ ! -f "$DMG_PATH" ]; then + echo "ERROR: DMG not found: $DMG_PATH" >&2; exit 1 +fi + +for var in APPLE_DEVELOPER_CERTIFICATE_P12 APPLE_DEVELOPER_CERTIFICATE_PASSWORD \ + APPLE_SIGNING_IDENTITY APPLE_ID APPLE_TEAM_ID \ + APPLE_APP_SPECIFIC_PASSWORD APPLE_KEYCHAIN_PASSWORD; do + if [ -z "${!var:-}" ]; then + echo "ERROR: required env var $var is not set" >&2 + exit 1 + fi +done + +APP_ABS="$(cd "$(dirname "$APP_PATH")" && pwd)/$(basename "$APP_PATH")" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +IDENTITY="Developer ID Application: ${APPLE_SIGNING_IDENTITY}" +KEYCHAIN_NAME="git-same-app-build-$$.keychain" +KEYCHAIN_PATH="$HOME/Library/Keychains/${KEYCHAIN_NAME}-db" +CERT_DIR="$(mktemp -d -t git-same-app-cert.XXXXXX)" +NOTARY_DIR="$(mktemp -d -t git-same-app-notary.XXXXXX)" +CERT_FILE="$CERT_DIR/cert.p12" + +cleanup() { + rm -rf "$CERT_DIR" "$NOTARY_DIR" || true + if security list-keychains | grep -q "$KEYCHAIN_NAME"; then + security delete-keychain "$KEYCHAIN_NAME" || true + fi + rm -f "$KEYCHAIN_PATH" || true +} +trap cleanup EXIT + +echo "==> Creating temp keychain" +security create-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" +security set-keychain-settings -lut 21600 "$KEYCHAIN_NAME" +security unlock-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" +security list-keychains -d user -s "$KEYCHAIN_NAME" "$(security list-keychains -d user | tr -d '"')" + +echo "==> Importing Developer ID certificate" +echo "$APPLE_DEVELOPER_CERTIFICATE_P12" | base64 -D > "$CERT_FILE" +security import "$CERT_FILE" \ + -k "$KEYCHAIN_NAME" \ + -P "$APPLE_DEVELOPER_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign \ + -T /usr/bin/security +security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s -k "$APPLE_KEYCHAIN_PASSWORD" \ + "$KEYCHAIN_NAME" >/dev/null + +sign_app() { + echo "==> Signing app bundle inside-out" + # The helper runs the monitor LaunchAgent and reads/writes the + # app-group container at ~/Library/Group Containers//. + # Without application-groups here macOS treats every group-container + # access as cross-app and shows a TCC AppData prompt attributed to + # "Git-Same.app". + /usr/bin/codesign --force --options runtime --timestamp \ + --sign "$IDENTITY" \ + --entitlements "$ROOT/crates/git-same-app/entitlements.plist" \ + "$APP_ABS/Contents/Helpers/git-same" + + if [ -d "$APP_ABS/Contents/PlugIns/GitSameBadges.appex" ]; then + /usr/bin/codesign --force --options runtime --timestamp \ + --sign "$IDENTITY" \ + --entitlements "$ROOT/macos/GitSameBadges/GitSameBadges.entitlements" \ + "$APP_ABS/Contents/PlugIns/GitSameBadges.appex" + fi + + /usr/bin/codesign --force --options runtime --timestamp \ + --sign "$IDENTITY" \ + --entitlements "$ROOT/crates/git-same-app/entitlements.plist" \ + "$APP_ABS/Contents/MacOS/git-same-app" + + /usr/bin/codesign --force --options runtime --timestamp \ + --sign "$IDENTITY" \ + --entitlements "$ROOT/crates/git-same-app/entitlements.plist" \ + "$APP_ABS" + + /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_ABS" + /usr/sbin/spctl --assess --type execute --verbose "$APP_ABS" + + verify_helper_entitlements +} + +verify_helper_entitlements() { + local helper="$APP_ABS/Contents/Helpers/git-same" + local expected_group + expected_group="$(/usr/libexec/PlistBuddy -c \ + "Print :com.apple.security.application-groups:0" \ + "$ROOT/crates/git-same-app/entitlements.plist")" + local actual + actual="$(/usr/bin/codesign -d --entitlements - "$helper" 2>&1)" + if ! printf '%s' "$actual" | grep -q "$expected_group"; then + echo "ERROR: helper binary is missing the application-groups entitlement." >&2 + echo " binary: $helper" >&2 + echo " expected group: $expected_group" >&2 + echo " codesign output:" >&2 + printf '%s\n' "$actual" | sed 's/^/ /' >&2 + echo "" >&2 + echo "Without this entitlement the monitor LaunchAgent triggers" >&2 + echo "a recurring \"would like to access data from other apps\" TCC" >&2 + echo "prompt whenever it touches the group container." >&2 + exit 1 + fi + echo "OK: helper signed with application-groups=$expected_group" +} + +notarize_app() { + local zip_path="$NOTARY_DIR/$(basename "$APP_ABS").zip" + echo "==> Zipping app for notarization" + /usr/bin/ditto -c -k --keepParent "$APP_ABS" "$zip_path" + echo "==> Submitting app to notarytool" + xcrun notarytool submit "$zip_path" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + --wait \ + --timeout 1200 + xcrun stapler staple "$APP_ABS" + xcrun stapler validate "$APP_ABS" +} + +sign_dmg() { + echo "==> Signing DMG" + /usr/bin/codesign --force --timestamp --sign "$IDENTITY" "$DMG_PATH" + if [ "$SKIP_NOTARIZATION" -eq 0 ]; then + echo "==> Submitting DMG to notarytool" + xcrun notarytool submit "$DMG_PATH" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + --wait \ + --timeout 1200 + xcrun stapler staple "$DMG_PATH" + fi +} + +if [ "$SKIP_APP" -eq 0 ]; then + sign_app + if [ "$SKIP_NOTARIZATION" -eq 0 ]; then + notarize_app + fi +fi + +if [ -n "$DMG_PATH" ]; then + sign_dmg +fi diff --git a/toolkit/packaging/release-checklist.md b/toolkit/packaging/release-checklist.md index d1b1726..975d4a8 100644 --- a/toolkit/packaging/release-checklist.md +++ b/toolkit/packaging/release-checklist.md @@ -11,7 +11,7 @@ manual `workflow_dispatch` workflows under `.github/workflows/`. - [ ] Smoke-render the Homebrew artifacts locally: ```sh bash toolkit/homebrew/render-cask.sh 3.X.Y --sha-arm <64x0> --sha-intel <64x0> - bash toolkit/homebrew/render-formula.sh 3.X.Y --url https://example --sha-macos-arm <64x0> --sha-macos-intel <64x0> --sha-linux-arm <64x0> --sha-linux-intel <64x0> + bash toolkit/homebrew/render-formula.sh 3.X.Y --kind cli --url https://example --sha-macos-arm <64x0> --sha-macos-intel <64x0> --sha-linux-arm <64x0> --sha-linux-intel <64x0> ``` ## 2. S1 (test CI) @@ -54,3 +54,4 @@ manual `workflow_dispatch` workflows under `.github/workflows/`. - [ ] On a clean Mac (x86_64): same as above. - [ ] On Linux (Docker is fine): `brew install zaai-com/tap/git-same-cli`. Same checks (sans `man` if unavailable). - [ ] `cargo install git-same` succeeds. +- [ ] Old `brew install zaai-com/tap/git-same` formula path still works and shows the deprecation notice.