added code coverage script and badges

This commit is contained in:
Sampo Kivistö 2026-03-08 22:52:47 +02:00
parent abe18e75e7
commit 5b7fbfd6ef
No known key found for this signature in database
GPG key ID: 3B426F446F481CFF
20 changed files with 467 additions and 85 deletions

59
.github/workflows/coverage.yml vendored Normal file
View file

@ -0,0 +1,59 @@
name: Coverage
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: coverage-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
APP_FEATURES: --no-default-features --features gix
jobs:
coverage:
name: Coverage (llvm-cov + Codecov)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- name: Cache Rust artifacts
uses: Swatinem/rust-cache@v2
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Run coverage
run: |
cargo llvm-cov --workspace \
--exclude gitcomet-ui \
--exclude gitcomet-ui-gpui \
$APP_FEATURES \
--lcov \
--output-path target/llvm-cov/lcov.info
- name: Upload coverage to Codecov (tokenless)
if: ${{ secrets.CODECOV_TOKEN == '' }}
uses: codecov/codecov-action@v5
with:
files: target/llvm-cov/lcov.info
flags: unittests
name: gitcomet-linux
fail_ci_if_error: false
- name: Upload coverage to Codecov (with token)
if: ${{ secrets.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@v5
with:
files: target/llvm-cov/lcov.info
flags: unittests
name: gitcomet-linux
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}

2
.gitignore vendored
View file

@ -1,4 +1,4 @@
/target
target/
**/*.rs.bk
.DS_Store
*.swp

View file

@ -25,6 +25,7 @@ edition = "2024"
license = "AGPL-3.0-only"
authors = ["AutoExplore Oy <info@autoexplore.ai>"]
repository = "https://github.com/Auto-Explore/GitComet"
rust-version = "1.94.0"
[workspace.dependencies]
# Internal crates

View file

@ -1,5 +1,8 @@
## GitComet
[![Build Status](https://github.com/Auto-Explore/GitComet/actions/workflows/rust.yml/badge.svg?branch=main)](https://github.com/Auto-Explore/GitComet/actions/workflows/rust.yml)
[![Coverage](https://codecov.io/gh/Auto-Explore/GitComet/branch/main/graph/badge.svg)](https://codecov.io/gh/Auto-Explore/GitComet)
Fast, resource-efficient, fully open source Git GUI written in Rust, targeting GitKraken/SourceTree/GitHub Desktop-class workflows using `gpui` for the UI.
### Goals
@ -160,6 +163,19 @@ Clippy (CI mode):
cargo clippy --workspace --no-default-features --features gix -- -D warnings
```
Coverage (local + CI-compatible):
```bash
rustup component add llvm-tools-preview
cargo install --locked cargo-llvm-cov
bash scripts/coverage.sh
```
This writes:
- `target/llvm-cov/lcov.info` (used by CI upload)
- `target/llvm-cov/html/index.html` (local detailed report)
The test suite covers:
- Core merge algorithm (ported from Git t6403/t6427)

View file

@ -5,6 +5,7 @@ edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[lints]
workspace = true

View file

@ -5,6 +5,7 @@ edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[lints]
workspace = true

View file

@ -5,6 +5,7 @@ edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[lints]
workspace = true

View file

@ -44,11 +44,8 @@ impl GixRepo {
});
}
gix::status::index_worktree::Item::DirectoryContents { entry, .. } => {
let kind = match entry.status {
gix::dir::entry::Status::Untracked => FileStatusKind::Untracked,
gix::dir::entry::Status::Ignored(_) => continue,
gix::dir::entry::Status::Tracked => FileStatusKind::Modified,
gix::dir::entry::Status::Pruned => continue,
let Some(kind) = map_directory_entry_status(entry.status) else {
continue;
};
let path = path_buf_from_git_bytes(
@ -307,10 +304,24 @@ fn map_entry_status<T, U>(
}
}
fn map_directory_entry_status(status: gix::dir::entry::Status) -> Option<FileStatusKind> {
match status {
// Directory-walk entries represent an unstaged change only when they are
// genuinely untracked. `Tracked` entries are traversal metadata and must
// not become synthetic "modified" files.
gix::dir::entry::Status::Untracked => Some(FileStatusKind::Untracked),
gix::dir::entry::Status::Ignored(_)
| gix::dir::entry::Status::Tracked
| gix::dir::entry::Status::Pruned => None,
}
}
#[cfg(test)]
mod tests {
use super::{collect_unmerged_conflicts, conflict_kind_from_stage_mask};
use gitcomet_core::domain::FileConflictKind;
use super::{
collect_unmerged_conflicts, conflict_kind_from_stage_mask, map_directory_entry_status,
};
use gitcomet_core::domain::{FileConflictKind, FileStatusKind};
use rustc_hash::FxHashMap as HashMap;
use std::path::PathBuf;
@ -414,4 +425,24 @@ mod tests {
vec![(PathBuf::from("conflicted.txt"), FileConflictKind::BothAdded)]
);
}
#[test]
fn map_directory_entry_status_only_reports_untracked_entries() {
use gix::dir::entry::Status;
assert_eq!(
map_directory_entry_status(Status::Untracked),
Some(FileStatusKind::Untracked)
);
assert_eq!(map_directory_entry_status(Status::Tracked), None);
assert_eq!(
map_directory_entry_status(Status::Ignored(gix::ignore::Kind::Expendable)),
None
);
assert_eq!(
map_directory_entry_status(Status::Ignored(gix::ignore::Kind::Precious)),
None
);
assert_eq!(map_directory_entry_status(Status::Pruned), None);
}
}

View file

@ -573,6 +573,53 @@ fn status_lists_untracked_files_in_directories() {
);
}
#[test]
fn status_ignores_nested_target_directories_with_target_slash_pattern() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, ".gitignore", "target/\n");
run_git(repo, &["add", ".gitignore"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init ignore"],
);
write(
repo,
"crates/gitcomet-ui-gpui/target/criterion/report/index.html",
"ignored\n",
);
write(repo, "visible.txt", "untracked\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let status = opened.status().unwrap();
assert!(
status.unstaged.iter().all(|entry| !entry
.path
.starts_with(Path::new("crates/gitcomet-ui-gpui/target"))),
"expected nested target/ contents to be ignored, got {status:?}"
);
assert!(
status
.unstaged
.iter()
.any(|entry| entry.path == Path::new("visible.txt")
&& entry.kind == FileStatusKind::Untracked),
"expected visible.txt as untracked, got {status:?}"
);
}
#[test]
fn diff_unified_works_for_staged_and_unstaged() {
if !require_git_shell_for_status_integration_tests() {

View file

@ -5,6 +5,7 @@ edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[lints]
workspace = true

View file

@ -5,6 +5,7 @@ edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[lints]
workspace = true

View file

@ -5,6 +5,7 @@ edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[lints]
workspace = true

View file

@ -324,6 +324,11 @@ impl TextInput {
self.selected_range.clone()
}
pub fn select_all_text(&mut self, cx: &mut Context<Self>) {
self.move_to(0, cx);
self.select_to(self.content.len(), cx);
}
pub fn set_soft_wrap(&mut self, soft_wrap: bool, cx: &mut Context<Self>) {
if self.soft_wrap == soft_wrap {
return;
@ -452,8 +457,7 @@ impl TextInput {
}
fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(0, cx);
self.select_to(self.content.len(), cx)
self.select_all_text(cx);
}
fn row_start(&self, offset: usize) -> usize {

View file

@ -70,30 +70,6 @@ pub fn pill(theme: AppTheme, label: impl Into<SharedString>, bg: gpui::Rgba) ->
.child(label.into())
}
pub fn key_value_monospace_value(
theme: AppTheme,
key: impl Into<SharedString>,
value: impl Into<SharedString>,
) -> Div {
div()
.flex()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.text_sm()
.text_color(theme.colors.text_muted)
.child(key.into()),
)
.child(
div()
.text_sm()
.font_family(crate::view::UI_MONOSPACE_FONT_FAMILY)
.child(value.into()),
)
}
pub fn empty_state(
theme: AppTheme,
title: impl Into<SharedString>,

View file

@ -10,7 +10,7 @@ mod toast;
mod tokens;
pub use button::{Button, ButtonStyle};
pub use containers::{empty_state, key_value_monospace_value, split_columns_header};
pub use containers::{empty_state, split_columns_header};
#[cfg(test)]
pub use containers::{panel, pill};
pub use context_menu::{

View file

@ -1,4 +1,5 @@
use super::*;
use gpui::Div;
fn merge_active(repo: Option<&RepoState>) -> bool {
repo.is_some_and(|r| matches!(&r.merge_commit_message, Loadable::Ready(Some(_))))
@ -8,7 +9,44 @@ fn commit_allowed(is_merge_active: bool, staged_count: usize) -> bool {
staged_count > 0 || is_merge_active
}
fn commit_details_selectable_row(
theme: AppTheme,
key: &'static str,
input: Entity<components::TextInput>,
) -> Div {
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.text_sm()
.text_color(theme.colors.text_muted)
.child(key),
)
.child(
div()
.w_full()
.min_w(px(0.0))
.text_sm()
.font_family(crate::view::UI_MONOSPACE_FONT_FAMILY)
.child(input),
)
}
impl DetailsPaneView {
fn sync_commit_details_input_value(
input: &Entity<components::TextInput>,
value: &str,
cx: &mut gpui::Context<Self>,
) {
if input.read(cx).text() != value {
input.update(cx, |input, cx| {
input.set_text(value.to_string(), cx);
});
}
}
pub(in super::super) fn commit_details_view(
&mut self,
cx: &mut gpui::Context<Self>,
@ -152,6 +190,21 @@ impl DetailsPaneView {
input.set_text(details.message.clone(), cx);
});
}
Self::sync_commit_details_input_value(
&self.commit_details_sha_input,
details.id.as_ref(),
cx,
);
Self::sync_commit_details_input_value(
&self.commit_details_date_input,
details.committed_at.as_str(),
cx,
);
Self::sync_commit_details_input_value(
&self.commit_details_parent_input,
parent.as_str(),
cx,
);
let message = div()
.id(("commit_details_message_container", repo_id.0))
@ -192,36 +245,21 @@ impl DetailsPaneView {
.w_full()
.min_w(px(0.0))
.child(message)
.child(components::key_value_monospace_value(
.child(commit_details_selectable_row(
theme,
"Commit SHA",
details.id.as_ref().to_string(),
self.commit_details_sha_input.clone(),
))
.child(components::key_value_monospace_value(
.child(commit_details_selectable_row(
theme,
"Commit date",
details.committed_at.clone(),
self.commit_details_date_input.clone(),
))
.child(
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.text_sm()
.text_color(theme.colors.text_muted)
.child("Parent commit SHA"),
)
.child(
div()
.text_sm()
.font_family("monospace")
.whitespace_nowrap()
.line_clamp(1)
.child(parent),
),
),
.child(commit_details_selectable_row(
theme,
"Parent commit SHA",
self.commit_details_parent_input.clone(),
)),
)
.child(
div()
@ -295,6 +333,21 @@ impl DetailsPaneView {
input.set_text(details.message.clone(), cx);
});
}
Self::sync_commit_details_input_value(
&self.commit_details_sha_input,
details.id.as_ref(),
cx,
);
Self::sync_commit_details_input_value(
&self.commit_details_date_input,
details.committed_at.as_str(),
cx,
);
Self::sync_commit_details_input_value(
&self.commit_details_parent_input,
parent.as_str(),
cx,
);
let message = div()
.id(("commit_details_message_container", repo_id.0))
@ -335,36 +388,21 @@ impl DetailsPaneView {
.w_full()
.min_w(px(0.0))
.child(message)
.child(components::key_value_monospace_value(
.child(commit_details_selectable_row(
theme,
"Commit SHA",
details.id.as_ref().to_string(),
self.commit_details_sha_input.clone(),
))
.child(components::key_value_monospace_value(
.child(commit_details_selectable_row(
theme,
"Commit date",
details.committed_at.clone(),
self.commit_details_date_input.clone(),
))
.child(
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.text_sm()
.text_color(theme.colors.text_muted)
.child("Parent commit SHA"),
)
.child(
div()
.text_sm()
.font_family("monospace")
.whitespace_nowrap()
.line_clamp(1)
.child(parent),
),
),
.child(commit_details_selectable_row(
theme,
"Parent commit SHA",
self.commit_details_parent_input.clone(),
)),
)
.child(
div()

View file

@ -465,8 +465,7 @@ fn added_file_preview_ctrl_a_ctrl_c_copies_all_content(cx: &mut gpui::TestAppCon
std::process::id()
));
let file_rel = std::path::PathBuf::from("added.rs");
let lines: Arc<Vec<String>> =
Arc::new(vec!["alpha".into(), "beta".into(), "gamma".into()]);
let lines: Arc<Vec<String>> = Arc::new(vec!["alpha".into(), "beta".into(), "gamma".into()]);
assert_file_preview_ctrl_a_ctrl_c_copies_all(
cx,
repo_id,
@ -496,6 +495,94 @@ fn deleted_file_preview_ctrl_a_ctrl_c_copies_all_content(cx: &mut gpui::TestAppC
);
}
#[gpui::test]
fn commit_details_metadata_fields_are_selectable(cx: &mut gpui::TestAppContext) {
let (store, events) = AppStore::new(Arc::new(TestBackend));
let (view, cx) = cx.add_window_view(|window, cx| {
super::super::GitCometView::new(store, events, None, window, cx)
});
let repo_id = gitcomet_state::model::RepoId(33);
let commit_sha = "0123456789abcdef0123456789abcdef01234567".to_string();
let parent_sha = "89abcdef0123456789abcdef0123456789abcdef".to_string();
let commit_date = "2026-03-08 12:34:56 +0200".to_string();
cx.update(|_window, app| {
view.update(app, |this, cx| {
let mut repo = gitcomet_state::model::RepoState::new_opening(
repo_id,
gitcomet_core::domain::RepoSpec {
workdir: std::path::PathBuf::from("/tmp/repo-commit-metadata-copy"),
},
);
repo.selected_commit = Some(gitcomet_core::domain::CommitId(commit_sha.clone()));
repo.commit_details = gitcomet_state::model::Loadable::Ready(Arc::new(
gitcomet_core::domain::CommitDetails {
id: gitcomet_core::domain::CommitId(commit_sha.clone()),
message: "subject".to_string(),
committed_at: commit_date.clone(),
parent_ids: vec![gitcomet_core::domain::CommitId(parent_sha.clone())],
files: vec![],
},
));
let next_state = Arc::new(AppState {
repos: vec![repo],
active_repo: Some(repo_id),
..Default::default()
});
this._ui_model.update(cx, |model, cx| {
model.set_state(next_state, cx);
});
});
});
cx.update(|window, app| {
let _ = window.draw(app);
});
cx.update(|_window, app| {
let details_pane = view.read(app).details_pane.clone();
let pane = details_pane.read(app);
assert_eq!(pane.commit_details_sha_input.read(app).text(), commit_sha);
assert_eq!(pane.commit_details_date_input.read(app).text(), commit_date);
assert_eq!(
pane.commit_details_parent_input.read(app).text(),
parent_sha
);
});
cx.update(|_window, app| {
let details_pane = view.read(app).details_pane.clone();
details_pane.update(app, |pane, cx| {
pane.commit_details_sha_input
.update(cx, |input, cx| input.select_all_text(cx));
pane.commit_details_date_input
.update(cx, |input, cx| input.select_all_text(cx));
pane.commit_details_parent_input
.update(cx, |input, cx| input.select_all_text(cx));
});
});
cx.update(|_window, app| {
let details_pane = view.read(app).details_pane.clone();
let pane = details_pane.read(app);
assert_eq!(
pane.commit_details_sha_input.read(app).selected_text(),
Some(commit_sha)
);
assert_eq!(
pane.commit_details_date_input.read(app).selected_text(),
Some(commit_date)
);
assert_eq!(
pane.commit_details_parent_input.read(app).selected_text(),
Some(parent_sha)
);
});
}
#[gpui::test]
fn switching_active_repo_clears_commit_message_input(cx: &mut gpui::TestAppContext) {
let (store, events) = AppStore::new(Arc::new(TestBackend));

View file

@ -20,6 +20,9 @@ pub(in super::super) struct DetailsPaneView {
pub(in super::super) commit_message_input: Entity<components::TextInput>,
pub(in super::super) commit_details_message_input: Entity<components::TextInput>,
pub(in super::super) commit_details_sha_input: Entity<components::TextInput>,
pub(in super::super) commit_details_date_input: Entity<components::TextInput>,
pub(in super::super) commit_details_parent_input: Entity<components::TextInput>,
pub(in super::super) commit_message_user_edited: bool,
pub(in super::super) commit_message_last_text: SharedString,
pub(in super::super) commit_message_programmatic_change: bool,
@ -103,6 +106,48 @@ impl DetailsPaneView {
)
});
let commit_details_sha_input = cx.new(|cx| {
components::TextInput::new(
components::TextInputOptions {
placeholder: "".into(),
multiline: false,
read_only: true,
chromeless: true,
soft_wrap: false,
},
window,
cx,
)
});
let commit_details_date_input = cx.new(|cx| {
components::TextInput::new(
components::TextInputOptions {
placeholder: "".into(),
multiline: false,
read_only: true,
chromeless: true,
soft_wrap: false,
},
window,
cx,
)
});
let commit_details_parent_input = cx.new(|cx| {
components::TextInput::new(
components::TextInputOptions {
placeholder: "".into(),
multiline: false,
read_only: true,
chromeless: true,
soft_wrap: false,
},
window,
cx,
)
});
let commit_message_subscription = cx.observe(&commit_message_input, |this, input, cx| {
let next: SharedString = input.read(cx).text().to_string().into();
if this.commit_message_programmatic_change {
@ -133,6 +178,9 @@ impl DetailsPaneView {
commit_scroll: ScrollHandle::new(),
commit_message_input,
commit_details_message_input,
commit_details_sha_input,
commit_details_date_input,
commit_details_parent_input,
commit_message_user_edited: false,
commit_message_last_text: SharedString::default(),
commit_message_programmatic_change: false,
@ -152,6 +200,12 @@ impl DetailsPaneView {
.update(cx, |input, cx| input.set_theme(theme, cx));
self.commit_details_message_input
.update(cx, |input, cx| input.set_theme(theme, cx));
self.commit_details_sha_input
.update(cx, |input, cx| input.set_theme(theme, cx));
self.commit_details_date_input
.update(cx, |input, cx| input.set_theme(theme, cx));
self.commit_details_parent_input
.update(cx, |input, cx| input.set_theme(theme, cx));
cx.notify();
}

View file

@ -5,6 +5,7 @@ edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[lints]
workspace = true

62
scripts/coverage.sh Executable file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
if ! command -v rustup >/dev/null 2>&1; then
echo "rustup is required to manage Rust toolchain components." >&2
exit 1
fi
if ! command -v cargo >/dev/null 2>&1; then
echo "cargo is required to run coverage." >&2
exit 1
fi
if ! rustup component list --installed | grep -Eq '^llvm-tools(-preview)?($|-)'; then
echo "Missing rustup component: llvm-tools (aka llvm-tools-preview)." >&2
echo "Install with: rustup component add llvm-tools-preview" >&2
exit 1
fi
if ! cargo llvm-cov --version >/dev/null 2>&1; then
echo "Missing cargo subcommand: cargo-llvm-cov" >&2
echo "Install with: cargo install --locked cargo-llvm-cov" >&2
exit 1
fi
coverage_dir="target/llvm-cov"
lcov_path="${coverage_dir}/lcov.info"
html_dir="${coverage_dir}/html"
mkdir -p "$coverage_dir"
cargo llvm-cov \
--workspace \
--exclude gitcomet-ui \
--exclude gitcomet-ui-gpui \
--no-default-features \
--features gix \
--lcov \
--output-path "$lcov_path" \
"$@"
cargo llvm-cov \
--workspace \
--exclude gitcomet-ui \
--exclude gitcomet-ui-gpui \
--no-default-features \
--features gix \
--html \
--output-dir "$html_dir" \
--no-run \
"$@"
if [[ -f "$lcov_path" && -f "${html_dir}/index.html" ]]; then
echo "Coverage summary generated."
echo "LCOV report: ${repo_root}/${lcov_path}"
echo "HTML report: ${repo_root}/${html_dir}/index.html"
else
echo "cargo llvm-cov completed."
fi