From a1a51c0a401eff1430ee8863b2fa13e444b19707 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sun, 28 Jun 2026 08:54:54 +0700 Subject: [PATCH 1/2] feat: add production audit and core git workflows --- .github/workflows/ci.yml | 165 ++++++++------------------ docs/production-audit.md | 92 ++++++++++++++ src-tauri/src/commands/maintenance.rs | 138 +++++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/repository.rs | 74 ++++++++++++ src-tauri/src/lib.rs | 11 +- src-tauri/src/models/git.rs | 23 ++++ src/services/gitService.ts | 6 +- src/types/git.ts | 4 + 9 files changed, 393 insertions(+), 121 deletions(-) create mode 100644 docs/production-audit.md create mode 100644 src-tauri/src/commands/maintenance.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdee9e6..c7e530f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,18 +8,59 @@ on: workflow_dispatch: permissions: - actions: read contents: read - issues: write - pull-requests: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - build: - name: Build ${{ matrix.name }} + validate: + name: PR validation + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Install Linux dependencies for Tauri checks + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libwebkit2gtk-4.1-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + + - name: Install frontend dependencies + run: npm ci + + - name: Typecheck frontend + run: npm run typecheck + + - name: Build frontend + run: npm run build + + - name: Check Tauri backend + run: npm run tauri:check + + package: + name: Package ${{ matrix.name }} + needs: validate + if: github.event_name != 'pull_request' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -34,7 +75,6 @@ jobs: - os: macos-latest name: macOS artifact: gitpilot-macos - steps: - name: Checkout repository uses: actions/checkout@v5 @@ -43,6 +83,7 @@ jobs: uses: actions/setup-node@v5 with: node-version: 24 + cache: npm - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -65,15 +106,6 @@ jobs: - name: Install frontend dependencies run: npm ci - - name: Typecheck frontend - run: npm run typecheck - - - name: Build frontend - run: npm run build - - - name: Check Tauri backend - run: npm run tauri:check - - name: Build Tauri desktop app run: npm run tauri -- build @@ -81,106 +113,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact }} - path: | - src-tauri/target/release/bundle/** + path: src-tauri/target/release/bundle/** if-no-files-found: error - - comment-artifacts: - name: Comment artifact download instructions - needs: build - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' && always() && needs.build.result == 'success' - steps: - - name: Comment on pull request - uses: actions/github-script@v8 - continue-on-error: true - with: - script: | - const marker = ''; - const { owner, repo } = context.repo; - const issue_number = context.issue.number; - const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; - const artifactNames = ['gitpilot-windows', 'gitpilot-macos', 'gitpilot-linux']; - - try { - const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { - owner, - repo, - run_id: context.runId, - per_page: 100, - }); - const artifactByName = new Map(artifacts.map((artifact) => [artifact.name, artifact])); - const artifactLines = artifactNames.map((name) => { - const artifact = artifactByName.get(name); - const label = name.replace('gitpilot-', ''); - if (!artifact) { - return `- ${label}: \`${name}\` (open [workflow run](${runUrl}) to download)`; - } - const artifactUrl = `${runUrl}/artifacts/${artifact.id}`; - return `- ${label}: [\`${name}\`](${artifactUrl})`; - }); - - const body = [ - marker, - '### GitPilot build artifacts are ready', - '', - `One CI workflow run built all 3 desktop apps: [open workflow run](${runUrl}).`, - '', - 'Download links:', - '', - ...artifactLines, - ].join('\n'); - - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number, - per_page: 100, - }); - const existing = comments.find((comment) => - comment.user?.type === 'Bot' && comment.body?.includes(marker) - ); - - if (existing) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body, - }); - } - } catch (error) { - if (error.status === 403) { - core.warning(`Skipping PR artifact comment because the workflow token cannot write comments: ${error.message}`); - } else { - throw error; - } - } - - summarize-artifacts: - name: Summarize artifact downloads - needs: build - runs-on: ubuntu-latest - if: always() - steps: - - name: Write summary - run: | - { - echo "### GitPilot desktop build artifacts" - echo - echo "This workflow builds GitPilot on Linux, Windows, and macOS and uploads the desktop bundles as artifacts." - echo - echo "Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - echo - echo "Download artifacts from that run:" - echo "- gitpilot-linux" - echo "- gitpilot-windows" - echo "- gitpilot-macos" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/production-audit.md b/docs/production-audit.md new file mode 100644 index 0000000..c6c85ab --- /dev/null +++ b/docs/production-audit.md @@ -0,0 +1,92 @@ +# GitPilot Production Audit and GitKraken-Parity Roadmap + +## Executive summary +GitPilot has a promising Tauri/Rust command boundary, a React/Zustand frontend, and broad Git porcelain coverage, but it is not yet production-ready or at GitKraken parity. The strongest areas are local status/staging/commit/history/branch/merge/rebase/stash/tag/worktree primitives and an initial AI service. The main gaps are product depth: credential management, hosted-provider workflows, advanced graph UX, partial hunk staging, robust diff modes, multi-repo workspace semantics, crash/updater/telemetry controls, and performance instrumentation. + +This audit added backend contracts for previously missing core Git workflows: clone, init, reflog, submodule status/update, and bisect lifecycle. These are intentionally implemented in Rust/Tauri IPC so frontend screens can remain thin. + +## Feature comparison table +| Feature | GitPilot | GitKraken | Status | Gap severity | +|---|---|---|---|---| +| Open existing repo | Yes | Yes | Usable | P3 | +| Clone repository | Backend IPC added; UI pending | Yes | Partial | P1 | +| Init repository | Backend IPC added; UI pending | Yes | Partial | P1 | +| Status/stage/commit/amend | Yes | Yes | Usable | P2 | +| Partial hunk staging | Limited/no dedicated hunk model | Yes | Missing | P0 | +| Branch create/delete/switch | Yes | Yes | Usable | P2 | +| Merge/rebase/cherry-pick/revert/reset | Yes, porcelain-backed | Yes | Partial UX | P1 | +| Interactive rebase squash/fixup | Backend exists; UX basic | Yes | Partial | P1 | +| Stash manager | Basic list/apply/pop/drop/rename | Yes | Partial | P2 | +| Tags | Yes | Yes | Usable | P2 | +| Reflog | Backend IPC added; UI pending | Yes/undo history | Partial | P1 | +| Detached HEAD | Checkout commit exists | Yes | Partial | P2 | +| Worktrees | Backend exists | Limited | Competitive | P2 | +| Submodules | Backend IPC added; UI pending | Yes | Partial | P1 | +| Bisect | Backend IPC added; UI pending | No/limited | GitPilot+ potential | P2 | +| Commit graph | Basic graph string/rendering | Advanced lanes/labels | Gap | P1 | +| Search/filter/blame/file history | Search and blame exist | Yes | Partial | P1 | +| GitHub/GitLab/Bitbucket PRs | Not implemented | Yes | Missing | P0 | +| SSH/HTTPS auth and token vault | Relies on system git; no vault UI | Yes | Missing | P0 | +| Command palette/shortcuts | Limited settings type only | Yes | Missing | P1 | +| Multi-repo workspace | Recent repos only | Yes | Missing | P1 | +| Conflict resolver | Marker parser/editor exists | Visual 3-way | Partial | P1 | +| AI commit/diff/conflict help | Initial AI commands | Limited | Differentiator | P2 | +| Updater/crash/telemetry toggle | Not productionized | Yes | Missing | P0 | + +## Missing features list +### Core Git +- Partial hunk and line staging with reverse patch safety. +- Multi-commit cherry-pick and queued revert flows. +- First-class reflog browser and one-click undo built on reflog/reset/revert. +- UI for clone/init/submodule/bisect commands added in this patch. +- Visual 3-way conflict resolver with base/current/incoming panes. + +### Visualization +- Commit graph lane assignment, stable lane colors, branch/tag pills, avatars, selection virtualization, and drag/drop branch/rebase operations. + +### Productivity +- Global command palette (`Ctrl+Shift+P`), fuzzy repo/action search, configurable keyboard shortcuts, and quick actions. + +### Repository management +- Workspace model with repo groups, favorites, pinned/recent repos, per-repo settings, and tab persistence. + +### AI +- PR summary generation and hosted-provider posting. +- Diff risk summarization and review checklists. +- Conflict explanation that references parsed conflict blocks. + +### Enterprise +- Secure credential/token storage using OS keychain plugins. +- Crash logging with opt-in telemetry toggle. +- Signed auto-updater and release channels. + +## Bug list / risks +- `git pull` could not be run in this checkout because the current branch has no configured remote tracking branch. +- CI currently builds all desktop bundles on every PR, which makes PR validation expensive and artifact-heavy. +- Git operations are porcelain subprocess calls; acceptable for breadth, but long-running commands need cancellation/progress streaming. +- Recent repository storage is flat and not a workspace schema. +- AI key is represented in settings and should move to secure storage before production. + +## Refactor proposals +1. Introduce a typed Git operation layer that owns argument validation, command execution, cancellation, progress, and audit events. +2. Split Tauri commands into fast read commands and long-running task commands with progress events. +3. Add a frontend feature shell: command palette, workspace navigator, repository tabs, settings, and hosted-provider account area. +4. Replace graph string rendering with a lane-layout engine that can be benchmarked independently. +5. Add integration tests that create temporary repositories and exercise each command. + +## Roadmap +### MVP +- Wire clone/init/reflog/submodule/bisect IPC into UI. +- Add partial hunk staging and visual conflict improvements. +- Add command palette and keyboard shortcuts. + +### Beta +- Multi-repo workspaces, pinned/favorites, graph lanes, file history, PR checkout, credential vault. +- Add smoke/integration tests and performance baselines. + +### Production +- Signed updater, crash logging, telemetry toggle, hardened secret storage, provider integrations, release channels. +- CI split into PR validation and release packaging. + +### GitKraken+ parity +- Drag/drop rebase/branch operations, advanced PR review, author avatars, AI summaries, bisect UI, and repository insights dashboards. diff --git a/src-tauri/src/commands/maintenance.rs b/src-tauri/src/commands/maintenance.rs new file mode 100644 index 0000000..76c89d8 --- /dev/null +++ b/src-tauri/src/commands/maintenance.rs @@ -0,0 +1,138 @@ +use crate::{ + models::git::{BisectState, GitCommandOutput, GitError, ReflogEntry, SubmoduleInfo}, + services::git_service, +}; +use std::path::Path; + +#[tauri::command] +pub fn list_reflog(repo_path: String, limit: Option) -> Result, GitError> { + let count = limit.unwrap_or(100).clamp(1, 1000).to_string(); + let pretty = "%gd%x1f%H%x1f%gs"; + let max_count = format!("--max-count={count}"); + let format = format!("--format={pretty}"); + let out = git_service::git_text(&repo_path, &["reflog", &max_count, &format])?; + Ok(out + .lines() + .filter_map(|line| { + let mut parts = line.splitn(3, '\u{1f}'); + Some(ReflogEntry { + selector: parts.next()?.to_string(), + commit: parts.next()?.to_string(), + subject: parts.next().unwrap_or_default().to_string(), + }) + }) + .collect()) +} + +#[tauri::command] +pub fn list_submodules(repo_path: String) -> Result, GitError> { + let out = git_service::git(&repo_path, &["submodule", "status", "--recursive"])?; + if !out.success && out.stderr.contains("no submodule mapping") { + return Ok(vec![]); + } + if !out.success { + return Err(GitError::new( + "GIT_COMMAND_FAILED", + out.stderr.clone(), + out.stderr, + )); + } + Ok(out + .stdout + .lines() + .filter_map(|line| { + let status = line.chars().next().unwrap_or(' ').to_string(); + let rest = line.get(1..)?.trim(); + let mut parts = rest.split_whitespace(); + let commit = parts.next()?.to_string(); + let path = parts.next()?.to_string(); + let branch = parts + .next() + .map(|s| s.trim_matches(|c| c == '(' || c == ')').to_string()); + Some(SubmoduleInfo { + path, + commit, + branch, + status, + }) + }) + .collect()) +} + +#[tauri::command] +pub fn update_submodules( + repo_path: String, + init: bool, + recursive: bool, +) -> Result { + let mut args = vec!["submodule", "update"]; + if init { + args.push("--init"); + } + if recursive { + args.push("--recursive"); + } + git_service::git_checked(&repo_path, &args) +} + +#[tauri::command] +pub fn start_bisect( + repo_path: String, + bad: String, + good: String, +) -> Result { + if bad.trim().is_empty() || good.trim().is_empty() { + return Err(GitError::new( + "INVALID_BISECT_RANGE", + "Both bad and good revisions are required", + "", + )); + } + git_service::git_checked(&repo_path, &["bisect", "start", bad.trim(), good.trim()]) +} + +#[tauri::command] +pub fn mark_bisect(repo_path: String, verdict: String) -> Result { + match verdict.as_str() { + "good" | "bad" | "skip" => { + git_service::git_checked(&repo_path, &["bisect", verdict.as_str()]) + } + _ => Err(GitError::new( + "INVALID_BISECT_VERDICT", + "Bisect verdict must be good, bad, or skip", + "", + )), + } +} + +#[tauri::command] +pub fn reset_bisect(repo_path: String) -> Result { + git_service::git_checked(&repo_path, &["bisect", "reset"]) +} + +#[tauri::command] +pub fn get_bisect_state(repo_path: String) -> Result { + let git_dir = git_service::git_text(&repo_path, &["rev-parse", "--git-dir"])?; + let in_progress = Path::new(&repo_path) + .join(git_dir.trim()) + .join("BISECT_LOG") + .exists(); + let current = git_service::git_text(&repo_path, &["rev-parse", "--short", "HEAD"]) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let log = if in_progress { + git_service::git_text(&repo_path, &["bisect", "log"]) + .unwrap_or_default() + .lines() + .map(str::to_string) + .collect() + } else { + vec![] + }; + Ok(BisectState { + in_progress, + current, + log, + }) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f645fb7..c594f8c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod branch; pub mod commit; pub mod diff; pub mod history; +pub mod maintenance; pub mod merge; pub mod rebase; pub mod remote; diff --git a/src-tauri/src/commands/repository.rs b/src-tauri/src/commands/repository.rs index c91dd67..4157933 100644 --- a/src-tauri/src/commands/repository.rs +++ b/src-tauri/src/commands/repository.rs @@ -31,6 +31,80 @@ pub fn open_repository(path: String) -> Result { current_branch: branch, }) } + +#[tauri::command] +pub fn init_repository( + path: String, + initial_branch: Option, +) -> Result { + if !Path::new(&path).is_dir() { + return Err(GitError::new( + "INVALID_PATH", + "Repository path must be an existing directory", + "", + )); + } + let mut args = vec!["init"]; + if let Some(branch) = initial_branch.as_deref().filter(|b| !b.trim().is_empty()) { + args.push("--initial-branch"); + args.push(branch.trim()); + } + git_service::git_checked(&path, &args)?; + open_repository(path) +} +#[tauri::command] +pub fn clone_repository( + url: String, + destination: String, + branch: Option, + depth: Option, +) -> Result { + if url.trim().is_empty() { + return Err(GitError::new("INVALID_REMOTE", "Clone URL is required", "")); + } + if destination.trim().is_empty() { + return Err(GitError::new( + "INVALID_PATH", + "Destination path is required", + "", + )); + } + let destination_path = Path::new(&destination); + if destination_path.exists() + && destination_path + .read_dir() + .map(|mut e| e.next().is_some()) + .unwrap_or(true) + { + return Err(GitError::new( + "DESTINATION_NOT_EMPTY", + "Clone destination must be empty or not exist", + "", + )); + } + let parent = destination_path.parent().unwrap_or_else(|| Path::new(".")); + let leaf = destination_path + .file_name() + .map(|v| v.to_string_lossy().to_string()) + .ok_or_else(|| { + GitError::new("INVALID_PATH", "Destination must include a folder name", "") + })?; + let mut owned = vec!["clone".to_string()]; + if let Some(branch) = branch.filter(|b| !b.trim().is_empty()) { + owned.push("--branch".into()); + owned.push(branch.trim().into()); + } + if let Some(depth) = depth.filter(|d| *d > 0) { + owned.push("--depth".into()); + owned.push(depth.to_string()); + } + owned.push(url.trim().into()); + owned.push(leaf); + let refs: Vec<&str> = owned.iter().map(String::as_str).collect(); + git_service::git_checked(parent.to_string_lossy().as_ref(), &refs)?; + open_repository(destination) +} + #[tauri::command] pub fn list_recent_repositories() -> Result, String> { Ok(config_service::load()?.recent_repositories) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 15477f6..a56dce8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,8 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ commands::repository::validate_repository, commands::repository::open_repository, + commands::repository::init_repository, + commands::repository::clone_repository, commands::repository::list_recent_repositories, commands::repository::save_recent_repository, commands::repository::remove_recent_repository, @@ -77,7 +79,14 @@ pub fn run() { commands::ai::suggest_branch_name, commands::ai::resolve_conflict_block, commands::settings::get_settings, - commands::settings::save_settings + commands::settings::save_settings, + commands::maintenance::list_reflog, + commands::maintenance::list_submodules, + commands::maintenance::update_submodules, + commands::maintenance::start_bisect, + commands::maintenance::mark_bisect, + commands::maintenance::reset_bisect, + commands::maintenance::get_bisect_state ]) .run(tauri::generate_context!()) .expect("failed to run GitPilot"); diff --git a/src-tauri/src/models/git.rs b/src-tauri/src/models/git.rs index dc8e5b3..faa699b 100644 --- a/src-tauri/src/models/git.rs +++ b/src-tauri/src/models/git.rs @@ -172,3 +172,26 @@ pub struct SearchResult { pub subtitle: String, pub target: String, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReflogEntry { + pub selector: String, + pub commit: String, + pub subject: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubmoduleInfo { + pub path: String, + pub commit: String, + pub branch: Option, + pub status: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BisectState { + pub in_progress: bool, + pub current: Option, + pub log: Vec, +} diff --git a/src/services/gitService.ts b/src/services/gitService.ts index 4dd00a1..865932f 100644 --- a/src/services/gitService.ts +++ b/src/services/gitService.ts @@ -1,6 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { demoHistory, demoRepo, demoStatus } from '../demo/currentRepoDemo'; -import type {AiResponse,BranchInfo,CommitFile,CommitInfo,DiffResult,GitCommandOutput,GitStatus,ParsedConflictFile,RemoteInfo,RepositoryInfo,Settings,StashInfo,TagInfo,HistoryFilters,RebaseState,RebaseTodoItem,BlameLine,WorktreeInfo,SearchResult} from '../types/git'; +import type {AiResponse,BranchInfo,CommitFile,CommitInfo,DiffResult,GitCommandOutput,GitStatus,ParsedConflictFile,RemoteInfo,RepositoryInfo,Settings,StashInfo,TagInfo,HistoryFilters,RebaseState,RebaseTodoItem,BlameLine,WorktreeInfo,SearchResult,ReflogEntry,SubmoduleInfo,BisectState} from '../types/git'; const isTauriRuntime=()=>typeof window!=='undefined'&&'__TAURI_INTERNALS__' in window; const demoSettings={theme:'dark',gitPath:'git',defaultTargetBranch:'main',recentRepositories:[demoRepo.path],aiProvider:'ollama',aiApiKey:'',aiModel:'',validationCommands:[],shortcuts:[]}; @@ -25,7 +25,7 @@ const demoCall=(cmd:string,args:Record={}):Promise=>{ const call=(cmd:string,args:Record={})=>isTauriRuntime()?invoke(cmd,args):demoCall(cmd,args); export const gitService={ - openRepository:(path:string)=>call('open_repository',{path}), validateRepository:(path:string)=>call('validate_repository',{path}), listRecentRepositories:()=>call('list_recent_repositories'), saveRecentRepository:(path:string)=>call('save_recent_repository',{path}), removeRecentRepository:(path:string)=>call('remove_recent_repository',{path}), + openRepository:(path:string)=>call('open_repository',{path}), initRepository:(path:string,initialBranch?:string)=>call('init_repository',{path,initialBranch}), cloneRepository:(url:string,destination:string,branch?:string,depth?:number)=>call('clone_repository',{url,destination,branch,depth}), validateRepository:(path:string)=>call('validate_repository',{path}), listRecentRepositories:()=>call('list_recent_repositories'), saveRecentRepository:(path:string)=>call('save_recent_repository',{path}), removeRecentRepository:(path:string)=>call('remove_recent_repository',{path}), getStatus:(repoPath:string)=>call('get_status',{repoPath}), stageFile:(repoPath:string,filePath:string)=>call('stage_file',{repoPath,filePath}), unstageFile:(repoPath:string,filePath:string)=>call('unstage_file',{repoPath,filePath}), stageAll:(repoPath:string)=>call('stage_all',{repoPath}), unstageAll:(repoPath:string)=>call('unstage_all',{repoPath}), discardFile:(repoPath:string,filePath:string)=>call('discard_file',{repoPath,filePath}), deleteUntrackedFile:(repoPath:string,filePath:string)=>call('delete_untracked_file',{repoPath,filePath}), getDiff:(repoPath:string,filePath:string,cached:boolean)=>call('get_diff',{repoPath,filePath,cached}), getCommitFileDiff:(repoPath:string,commit:string,filePath:string)=>call('get_commit_file_diff',{repoPath,commit,filePath}), commit:(repoPath:string,message:string,amend:boolean)=>call('commit',{repoPath,message,amend}), stagedDiff:(repoPath:string)=>call('staged_diff',{repoPath}), listBranches:(repoPath:string)=>call('list_branches',{repoPath}), createBranch:(repoPath:string,name:string,checkout:boolean)=>call('create_branch',{repoPath,name,checkout}), checkoutBranch:(repoPath:string,name:string)=>call('checkout_branch',{repoPath,name}), renameBranch:(repoPath:string,oldName:string,newName:string)=>call('rename_branch',{repoPath,oldName,newName}), deleteBranch:(repoPath:string,name:string,force:boolean)=>call('delete_branch',{repoPath,name,force}), compareBranch:(repoPath:string,branch:string)=>call('compare_branch',{repoPath,branch}), @@ -35,5 +35,5 @@ export const gitService={ startRebase:(repoPath:string,onto:string)=>call('start_rebase',{repoPath,onto}), startInteractiveRebase:(repoPath:string,base:string,todo:RebaseTodoItem[])=>call('start_interactive_rebase',{repoPath,base,todo}), getRebaseState:(repoPath:string)=>call('get_rebase_state',{repoPath}), continueRebase:(repoPath:string)=>call('continue_rebase',{repoPath}), abortRebase:(repoPath:string)=>call('abort_rebase',{repoPath}), skipRebase:(repoPath:string)=>call('skip_rebase',{repoPath}), listStashes:(repoPath:string)=>call('list_stashes',{repoPath}), createStash:(repoPath:string,message:string)=>call('create_stash',{repoPath,message}), applyStash:(repoPath:string,stash:string)=>call('apply_stash',{repoPath,stash}), popStash:(repoPath:string,stash:string)=>call('pop_stash',{repoPath,stash}), dropStash:(repoPath:string,stash:string)=>call('drop_stash',{repoPath,stash}), renameStash:(repoPath:string,stash:string,message:string)=>call('rename_stash',{repoPath,stash,message}), listTags:(repoPath:string)=>call('list_tags',{repoPath}), createLightweightTag:(repoPath:string,name:string)=>call('create_lightweight_tag',{repoPath,name}), createAnnotatedTag:(repoPath:string,name:string,message:string)=>call('create_annotated_tag',{repoPath,name,message}), deleteTag:(repoPath:string,name:string)=>call('delete_tag',{repoPath,name}), pushTag:(repoPath:string,remote:string,name:string)=>call('push_tag',{repoPath,remote,name}), - runValidation:(repoPath:string)=>call('run_validation',{repoPath}), listWorktrees:(repoPath:string)=>call('list_worktrees',{repoPath}), createWorktree:(repoPath:string,path:string,branch:string,newBranch:boolean)=>call('create_worktree',{repoPath,path,branch,newBranch}), removeWorktree:(repoPath:string,path:string,force=false)=>call('remove_worktree',{repoPath,path,force}), smartSearch:(repoPath:string,query:string,limit=25)=>call('smart_search',{repoPath,query,limit}), explainDiff:(repoPath:string,diff:string,provider:string,model:string)=>call('explain_diff',{repoPath,diff,provider,model}), generateCommitMessage:(repoPath:string,provider:string,model:string)=>call('generate_commit_message',{repoPath,provider,model}), suggestBranchName:(description:string,provider:string,model:string)=>call('suggest_branch_name',{description,provider,model}), resolveConflictBlock:(current:string,incoming:string,provider:string,model:string)=>call('resolve_conflict_block',{current,incoming,provider,model}), getSettings:()=>call('get_settings'), saveSettings:(settings:Settings)=>call('save_settings',{settings}) + runValidation:(repoPath:string)=>call('run_validation',{repoPath}), listWorktrees:(repoPath:string)=>call('list_worktrees',{repoPath}), createWorktree:(repoPath:string,path:string,branch:string,newBranch:boolean)=>call('create_worktree',{repoPath,path,branch,newBranch}), removeWorktree:(repoPath:string,path:string,force=false)=>call('remove_worktree',{repoPath,path,force}), smartSearch:(repoPath:string,query:string,limit=25)=>call('smart_search',{repoPath,query,limit}), listReflog:(repoPath:string,limit=100)=>call('list_reflog',{repoPath,limit}), listSubmodules:(repoPath:string)=>call('list_submodules',{repoPath}), updateSubmodules:(repoPath:string,init=true,recursive=true)=>call('update_submodules',{repoPath,init,recursive}), startBisect:(repoPath:string,bad:string,good:string)=>call('start_bisect',{repoPath,bad,good}), markBisect:(repoPath:string,verdict:'good'|'bad'|'skip')=>call('mark_bisect',{repoPath,verdict}), resetBisect:(repoPath:string)=>call('reset_bisect',{repoPath}), getBisectState:(repoPath:string)=>call('get_bisect_state',{repoPath}), explainDiff:(repoPath:string,diff:string,provider:string,model:string)=>call('explain_diff',{repoPath,diff,provider,model}), generateCommitMessage:(repoPath:string,provider:string,model:string)=>call('generate_commit_message',{repoPath,provider,model}), suggestBranchName:(description:string,provider:string,model:string)=>call('suggest_branch_name',{description,provider,model}), resolveConflictBlock:(current:string,incoming:string,provider:string,model:string)=>call('resolve_conflict_block',{current,incoming,provider,model}), getSettings:()=>call('get_settings'), saveSettings:(settings:Settings)=>call('save_settings',{settings}) }; diff --git a/src/types/git.ts b/src/types/git.ts index cd24954..08ec3a2 100644 --- a/src/types/git.ts +++ b/src/types/git.ts @@ -22,3 +22,7 @@ export type RebaseState={inProgress:boolean;interactive:boolean;currentBranch?:s export type BlameLine={lineNumber:number;commit:string;author:string;timestamp:string;text:string}; export type WorktreeInfo={path:string;head:string;branch?:string|null;bare:boolean;detached:boolean}; export type SearchResult={kind:string;title:string;subtitle:string;target:string}; + +export type ReflogEntry={selector:string;commit:string;subject:string}; +export type SubmoduleInfo={path:string;commit:string;branch?:string|null;status:string}; +export type BisectState={inProgress:boolean;current?:string|null;log:string[]}; From 2be0b0ea01cab137959a7700b03074ac2ac9af97 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sun, 28 Jun 2026 09:01:58 +0700 Subject: [PATCH 2/2] fix: restore PR desktop artifacts --- .github/workflows/ci.yml | 164 ++++++++++++++++++++++++++++----------- docs/production-audit.md | 4 +- 2 files changed, 119 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7e530f..16bcb4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,59 +8,18 @@ on: workflow_dispatch: permissions: + actions: read contents: read + issues: write + pull-requests: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - validate: - name: PR validation - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v5 - - - name: Set up Node.js - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: npm - - - name: Install Rust stable - uses: dtolnay/rust-toolchain@stable - - - name: Install Linux dependencies for Tauri checks - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libwebkit2gtk-4.1-dev \ - libayatana-appindicator3-dev \ - librsvg2-dev \ - patchelf - - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: src-tauri -> target - - - name: Install frontend dependencies - run: npm ci - - - name: Typecheck frontend - run: npm run typecheck - - - name: Build frontend - run: npm run build - - - name: Check Tauri backend - run: npm run tauri:check - - package: - name: Package ${{ matrix.name }} - needs: validate - if: github.event_name != 'pull_request' + build: + name: Build ${{ matrix.name }} runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -75,6 +34,7 @@ jobs: - os: macos-latest name: macOS artifact: gitpilot-macos + steps: - name: Checkout repository uses: actions/checkout@v5 @@ -106,6 +66,15 @@ jobs: - name: Install frontend dependencies run: npm ci + - name: Typecheck frontend + run: npm run typecheck + + - name: Build frontend + run: npm run build + + - name: Check Tauri backend + run: npm run tauri:check + - name: Build Tauri desktop app run: npm run tauri -- build @@ -113,5 +82,106 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact }} - path: src-tauri/target/release/bundle/** + path: | + src-tauri/target/release/bundle/** if-no-files-found: error + + comment-artifacts: + name: Comment artifact download instructions + needs: build + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && always() && needs.build.result == 'success' + steps: + - name: Comment on pull request + uses: actions/github-script@v8 + continue-on-error: true + with: + script: | + const marker = ''; + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; + const artifactNames = ['gitpilot-windows', 'gitpilot-macos', 'gitpilot-linux']; + + try { + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: context.runId, + per_page: 100, + }); + const artifactByName = new Map(artifacts.map((artifact) => [artifact.name, artifact])); + const artifactLines = artifactNames.map((name) => { + const artifact = artifactByName.get(name); + const label = name.replace('gitpilot-', ''); + if (!artifact) { + return `- ${label}: \`${name}\` (open [workflow run](${runUrl}) to download)`; + } + const artifactUrl = `${runUrl}/artifacts/${artifact.id}`; + return `- ${label}: [\`${name}\`](${artifactUrl})`; + }); + + const body = [ + marker, + '### GitPilot build artifacts are ready', + '', + `One CI workflow run built all 3 desktop apps: [open workflow run](${runUrl}).`, + '', + 'Download links:', + '', + ...artifactLines, + ].join('\n'); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + const existing = comments.find((comment) => + comment.user?.type === 'Bot' && comment.body?.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } + } catch (error) { + if (error.status === 403) { + core.warning(`Skipping PR artifact comment because the workflow token cannot write comments: ${error.message}`); + } else { + throw error; + } + } + + summarize-artifacts: + name: Summarize artifact downloads + needs: build + runs-on: ubuntu-latest + if: always() + steps: + - name: Write summary + run: | + { + echo "### GitPilot desktop build artifacts" + echo + echo "This workflow builds GitPilot on Linux, Windows, and macOS and uploads the desktop bundles as artifacts." + echo + echo "Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + echo + echo "Download artifacts from that run:" + echo "- gitpilot-linux" + echo "- gitpilot-windows" + echo "- gitpilot-macos" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/production-audit.md b/docs/production-audit.md index c6c85ab..b1cd7e0 100644 --- a/docs/production-audit.md +++ b/docs/production-audit.md @@ -62,7 +62,7 @@ This audit added backend contracts for previously missing core Git workflows: cl ## Bug list / risks - `git pull` could not be run in this checkout because the current branch has no configured remote tracking branch. -- CI currently builds all desktop bundles on every PR, which makes PR validation expensive and artifact-heavy. +- CI intentionally builds and uploads Linux, Windows, and macOS desktop bundles on every PR so reviewers can download platform artifacts; the tradeoff is slower validation until a separate opt-in fast-check workflow exists. - Git operations are porcelain subprocess calls; acceptable for breadth, but long-running commands need cancellation/progress streaming. - Recent repository storage is flat and not a workspace schema. - AI key is represented in settings and should move to secure storage before production. @@ -86,7 +86,7 @@ This audit added backend contracts for previously missing core Git workflows: cl ### Production - Signed updater, crash logging, telemetry toggle, hardened secret storage, provider integrations, release channels. -- CI split into PR validation and release packaging. +- Keep PR artifact builds for reviewer access, then add an optional fast-check workflow or label-gated packaging path if CI minutes become a bottleneck. ### GitKraken+ parity - Drag/drop rebase/branch operations, advanced PR review, author avatars, AI summaries, bisect UI, and repository insights dashboards.