diff --git a/README.md b/README.md
index 24edc94..6a8ecc3 100644
--- a/README.md
+++ b/README.md
@@ -184,6 +184,7 @@ The default key bindings can be overridden. Please refer to [default-keybind.tom
| ------------------------------ | ----------- | --------------------- |
| Ctrl-c q | Quit app | `force_quit` `quit` |
| ? | Open help | `help_toggle` |
+| r | Refresh | `refresh` |
#### Commit List
@@ -200,11 +201,14 @@ The default key bindings can be overridden. Please refer to [default-keybind.tom
| Enter | Show commit details
Apply search (if searching) | `confirm` |
| Tab | Open refs list | `ref_list_toggle` |
| / | Start search | `search` |
-| Esc | Cancel search | `cancel` |
+| f | Start filter | `filter` |
+| Esc | Cancel search/filter | `cancel` |
| n/N | Go to next/previous search match | `go_to_next` `go_to_previous` |
-| Ctrl-g | Toggle ignore case (if searching) | `ignore_case_toggle` |
-| Ctrl-x | Toggle fuzzy match (if searching) | `fuzzy_toggle` |
+| Alt-c | Toggle ignore case (if searching/filtering) | `ignore_case_toggle` |
+| Ctrl-x | Toggle fuzzy match (if searching/filtering) | `fuzzy_toggle` |
| c/C | Copy commit short/full hash | `short_copy` `full_copy` |
+| t | Create tag on commit | `create_tag` |
+| Ctrl-t | Delete tag from commit | `delete_tag` |
| d | Toggle custom user command view | `user_command_view_toggle_1` |
#### Commit Detail
@@ -223,14 +227,15 @@ The default key bindings can be overridden. Please refer to [default-keybind.tom
#### Refs List
-| Key | Description | Corresponding keybind |
-| -------------------------------------------------- | ---------------- | ---------------------------------- |
-| Esc Backspace Tab | Close refs list | `close` `cancel` `ref_list_toggle` |
-| Down/Up j/k | Move down/up | `navigate_down` `navigate_up` |
-| J/K | Move down/up | `select_down` `select_up` |
-| g/G | Go to top/bottom | `go_to_top` `go_to_bottom` |
-| Right/Left l/h | Open/Close node | `navigate_right` `navigate_left` |
-| c | Copy ref name | `short_copy` |
+| Key | Description | Corresponding keybind |
+| -------------------------------------------------- | ------------------------- | ---------------------------------- |
+| Esc Backspace Tab | Close refs list | `close` `cancel` `ref_list_toggle` |
+| Down/Up j/k | Move down/up | `navigate_down` `navigate_up` |
+| J/K | Move down/up | `select_down` `select_up` |
+| g/G | Go to top/bottom | `go_to_top` `go_to_bottom` |
+| Right/Left l/h | Open/Close node | `navigate_right` `navigate_left` |
+| c | Copy ref name | `short_copy` |
+| d | Delete branch/tag | `user_command_view_toggle_1` |
#### User Command
diff --git a/assets/default-keybind.toml b/assets/default-keybind.toml
index 6fa3583..949debf 100644
--- a/assets/default-keybind.toml
+++ b/assets/default-keybind.toml
@@ -29,7 +29,8 @@ go_to_previous = ["shift-n"]
confirm = ["enter"]
ref_list_toggle = ["tab"]
search = ["/"]
-ignore_case_toggle = ["ctrl-g"]
+filter = ["f"]
+ignore_case_toggle = ["alt-c"]
fuzzy_toggle = ["ctrl-x"]
user_command_view_toggle_1 = ["d"]
@@ -37,3 +38,8 @@ user_command_view_toggle_1 = ["d"]
# copy part of information, ex: copy the short commit hash not all
short_copy = ["c"]
full_copy = ["shift-c"]
+
+create_tag = ["t"]
+delete_tag = ["ctrl-t"]
+
+refresh = ["r"]
diff --git a/scripts/generate_test_repo.sh b/scripts/generate_test_repo.sh
new file mode 100755
index 0000000..76db58c
--- /dev/null
+++ b/scripts/generate_test_repo.sh
@@ -0,0 +1,259 @@
+#!/bin/bash
+#
+# Generate test git repository with realistic branch patterns
+#
+# Usage: ./generate_test_repo.sh [path] [commit_count]
+# ./generate_test_repo.sh /tmp/my-repo 5000
+#
+# Creates a repository with:
+# - Main branch with linear history
+# - feature/xxx and bugfix/xxx branches forking from main
+# - Each branch lives 2-8 commits before merging back
+# - Merges use --no-ff to preserve merge commits
+# - Max 5 concurrent branches
+# - Semver tags (v0.1.0, v0.2.3, ...)
+# - Multiple authors with realistic dates spread over 2 years
+#
+# Resulting graph looks like typical "christmas tree":
+#
+# * merge feature/auth-42
+# |\
+# | * feat(auth): add auth logic
+# | * feat(auth): implement auth logic
+# |/
+# * fix: merge bugfix/timeout-15
+# |\
+# | * fix(api): fix api logic
+# |/
+# * feat(cache): update cache logic
+# * chore: initial commit
+#
+
+set -e
+
+REPO_DIR="${1:-/tmp/test-repo-10k}"
+COMMIT_COUNT="${2:-10000}"
+
+rm -rf "$REPO_DIR"
+mkdir -p "$REPO_DIR"
+cd "$REPO_DIR"
+git init
+git config user.email "dev@example.com"
+git config user.name "Developer"
+
+# Arrays for random content
+AUTHORS=(
+ "Alice Smith:alice@example.com"
+ "Bob Johnson:bob@example.com"
+ "Charlie Brown:charlie@example.com"
+ "Diana Prince:diana@example.com"
+ "Eve Wilson:eve@example.com"
+)
+
+PREFIXES=("feat" "fix" "refactor" "docs" "test" "chore" "perf")
+
+FEATURES=(
+ "auth" "api" "database" "cache" "search" "upload" "export"
+ "notifications" "settings" "dashboard" "reports" "billing"
+ "users" "permissions" "logging" "metrics" "config" "cli"
+)
+
+BUGS=(
+ "login-redirect" "null-pointer" "memory-leak" "timeout" "encoding"
+ "validation" "race-condition" "deadlock" "overflow" "parsing"
+ "connection-drop" "cache-invalidation" "timezone" "locale"
+)
+
+ACTIONS=("add" "update" "fix" "improve" "implement" "refactor" "optimize")
+
+random_element() {
+ local arr=("$@")
+ echo "${arr[$RANDOM % ${#arr[@]}]}"
+}
+
+random_author() {
+ local info=$(random_element "${AUTHORS[@]}")
+ echo "$info"
+}
+
+generate_commit_message() {
+ local prefix=$(random_element "${PREFIXES[@]}")
+ local feature=$(random_element "${FEATURES[@]}")
+ local action=$(random_element "${ACTIONS[@]}")
+ echo "$prefix($feature): $action ${feature} logic"
+}
+
+generate_file_content() {
+ echo "// Updated at $(date +%s%N)"
+ echo "// Random: $RANDOM"
+ for i in {1..10}; do
+ echo "fn func_$RANDOM() { /* ... */ }"
+ done
+}
+
+make_commit() {
+ local message="$1"
+ local author_info="$2"
+ local commit_date="$3"
+
+ local author_name="${author_info%%:*}"
+ local author_email="${author_info##*:}"
+
+ local component=$(random_element "${FEATURES[@]}")
+ local file_path="src/${component}.rs"
+ mkdir -p "$(dirname "$file_path")"
+ generate_file_content > "$file_path"
+ git add "$file_path"
+
+ GIT_AUTHOR_NAME="$author_name" \
+ GIT_AUTHOR_EMAIL="$author_email" \
+ GIT_AUTHOR_DATE="$commit_date" \
+ GIT_COMMITTER_NAME="$author_name" \
+ GIT_COMMITTER_EMAIL="$author_email" \
+ GIT_COMMITTER_DATE="$commit_date" \
+ git commit -m "$message" --quiet 2>/dev/null || true
+}
+
+echo "Generating $COMMIT_COUNT commits in $REPO_DIR..."
+
+# Initial commit
+mkdir -p src
+echo "# Test Repository" > README.md
+echo "fn main() {}" > src/main.rs
+git add .
+git commit -m "chore: initial commit" --quiet
+
+start_time=$(date +%s)
+base_date=$(date -d "2023-01-01" +%s 2>/dev/null || date -j -f "%Y-%m-%d" "2023-01-01" +%s 2>/dev/null || echo "1672531200")
+seconds_per_commit=$(( 730 * 24 * 3600 / COMMIT_COUNT )) # spread over 2 years
+
+commit_num=0
+active_branches=()
+next_branch_at=$((RANDOM % 20 + 5))
+branch_counter=0
+version_major=0
+version_minor=0
+version_patch=0
+
+while [ $commit_num -lt $COMMIT_COUNT ]; do
+ # Progress
+ if [ $((commit_num % 500)) -eq 0 ] && [ $commit_num -gt 0 ]; then
+ elapsed=$(($(date +%s) - start_time))
+ rate=$((commit_num / (elapsed + 1)))
+ echo "Progress: $commit_num/$COMMIT_COUNT ($rate/sec)"
+ fi
+
+ current_date=$((base_date + commit_num * seconds_per_commit + RANDOM % 3600))
+ commit_date=$(date -d "@$current_date" --iso-8601=seconds 2>/dev/null || date -r "$current_date" +%Y-%m-%dT%H:%M:%S 2>/dev/null || echo "2023-06-15T12:00:00")
+ author=$(random_author)
+
+ # Decide: work on main or create/continue feature branch
+ if [ ${#active_branches[@]} -eq 0 ] || [ $((RANDOM % 3)) -eq 0 ]; then
+ # Work on main
+ git checkout main --quiet 2>/dev/null || git checkout -b main --quiet
+
+ # Maybe start a new branch
+ if [ $commit_num -ge $next_branch_at ] && [ ${#active_branches[@]} -lt 5 ]; then
+ branch_counter=$((branch_counter + 1))
+
+ # Feature or bugfix?
+ if [ $((RANDOM % 3)) -eq 0 ]; then
+ bug=$(random_element "${BUGS[@]}")
+ branch_name="bugfix/${bug}-${branch_counter}"
+ branch_type="bugfix"
+ else
+ feature=$(random_element "${FEATURES[@]}")
+ branch_name="feature/${feature}-${branch_counter}"
+ branch_type="feature"
+ fi
+
+ git checkout -b "$branch_name" --quiet
+ active_branches+=("$branch_name:$branch_type:1")
+ next_branch_at=$((commit_num + RANDOM % 30 + 10))
+
+ make_commit "$(generate_commit_message)" "$author" "$commit_date"
+ commit_num=$((commit_num + 1))
+ else
+ # Regular main commit
+ make_commit "$(generate_commit_message)" "$author" "$commit_date"
+ commit_num=$((commit_num + 1))
+
+ # Maybe tag a release
+ if [ $((RANDOM % 100)) -eq 0 ]; then
+ version_patch=$((version_patch + 1))
+ if [ $version_patch -ge 10 ]; then
+ version_patch=0
+ version_minor=$((version_minor + 1))
+ fi
+ if [ $version_minor -ge 10 ]; then
+ version_minor=0
+ version_major=$((version_major + 1))
+ fi
+ git tag "v${version_major}.${version_minor}.${version_patch}" 2>/dev/null || true
+ fi
+ fi
+ else
+ # Work on existing branch
+ idx=$((RANDOM % ${#active_branches[@]}))
+ branch_info="${active_branches[$idx]}"
+ branch_name="${branch_info%%:*}"
+ rest="${branch_info#*:}"
+ branch_type="${rest%%:*}"
+ branch_commits="${rest##*:}"
+
+ git checkout "$branch_name" --quiet 2>/dev/null || continue
+
+ # Add commit to branch
+ make_commit "$(generate_commit_message)" "$author" "$commit_date"
+ commit_num=$((commit_num + 1))
+ branch_commits=$((branch_commits + 1))
+
+ # Update branch info
+ active_branches[$idx]="$branch_name:$branch_type:$branch_commits"
+
+ # Maybe merge back to main (after 2-8 commits)
+ if [ $branch_commits -ge $((RANDOM % 7 + 2)) ]; then
+ git checkout main --quiet
+
+ # Merge with descriptive message
+ if [ "$branch_type" = "bugfix" ]; then
+ merge_msg="fix: merge $branch_name"
+ else
+ merge_msg="feat: merge $branch_name"
+ fi
+
+ git merge --no-ff "$branch_name" -m "$merge_msg" --quiet 2>/dev/null || {
+ git merge --abort 2>/dev/null || true
+ git checkout main --quiet
+ }
+
+ git branch -d "$branch_name" --quiet 2>/dev/null || true
+
+ # Remove from active branches
+ unset 'active_branches[$idx]'
+ active_branches=("${active_branches[@]}")
+ fi
+ fi
+done
+
+# Merge remaining branches
+git checkout main --quiet 2>/dev/null || true
+for branch_info in "${active_branches[@]}"; do
+ branch_name="${branch_info%%:*}"
+ if git show-ref --verify --quiet "refs/heads/$branch_name" 2>/dev/null; then
+ git merge --no-ff "$branch_name" -m "feat: merge $branch_name" --quiet 2>/dev/null || git merge --abort 2>/dev/null || true
+ git branch -d "$branch_name" --quiet 2>/dev/null || true
+ fi
+done
+
+end_time=$(date +%s)
+elapsed=$((end_time - start_time))
+
+echo ""
+echo "Done!"
+echo "Repository: $REPO_DIR"
+echo "Commits: $(git rev-list --count HEAD)"
+echo "Tags: $(git tag | wc -l)"
+echo "Time: ${elapsed}s"
+echo ""
+echo "Test with: serie $REPO_DIR"
diff --git a/src/app.rs b/src/app.rs
index 9c7d588..45d6c10 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,11 +1,11 @@
-use std::collections::HashMap;
+use std::{collections::HashMap, rc::Rc};
use ratatui::{
backend::Backend,
crossterm::event::{KeyCode, KeyEvent},
layout::{Constraint, Layout, Rect},
style::{Modifier, Style, Stylize},
- text::Line,
+ text::{Line, Span},
widgets::{Block, Borders, Padding, Paragraph},
Frame, Terminal,
};
@@ -15,12 +15,15 @@ use crate::{
config::{CoreConfig, CursorType, UiConfig},
event::{AppEvent, Receiver, Sender, UserEvent, UserEventWithCount},
external::copy_to_clipboard,
- git::{Head, Repository},
- graph::{CellWidthType, Graph, GraphImageManager},
+ git::{CommitHash, Head, Ref, RefType, Repository},
+ graph::{calc_graph, CellWidthType, Graph, GraphImageManager},
keybind::KeyBind,
protocol::ImageProtocol,
view::View,
- widget::commit_list::{CommitInfo, CommitListState},
+ widget::{
+ commit_list::{CommitInfo, CommitListState},
+ pending_overlay::PendingOverlay,
+ },
};
#[derive(Debug)]
@@ -40,14 +43,17 @@ pub enum InitialSelection {
#[derive(Debug)]
pub struct App<'a> {
- repository: &'a Repository,
+ repository: Repository,
view: View<'a>,
status_line: StatusLine,
+ pending_message: Option,
keybind: &'a KeyBind,
core_config: &'a CoreConfig,
ui_config: &'a UiConfig,
color_theme: &'a ColorTheme,
+ graph_color_set: &'a GraphColorSet,
+ cell_width_type: CellWidthType,
image_protocol: ImageProtocol,
tx: Sender,
@@ -57,9 +63,9 @@ pub struct App<'a> {
impl<'a> App<'a> {
pub fn new(
- repository: &'a Repository,
- graph_image_manager: GraphImageManager<'a>,
- graph: &'a Graph,
+ repository: Repository,
+ graph_image_manager: GraphImageManager,
+ graph: &Graph,
keybind: &'a KeyBind,
core_config: &'a CoreConfig,
ui_config: &'a UiConfig,
@@ -78,11 +84,11 @@ impl<'a> App<'a> {
.map(|(i, commit)| {
let refs = repository.refs(&commit.commit_hash);
for r in &refs {
- ref_name_to_commit_index_map.insert(r.name(), i);
+ ref_name_to_commit_index_map.insert(r.name().to_string(), i);
}
let (pos_x, _) = graph.commit_pos_map[&commit.commit_hash];
let graph_color = graph_color_set.get(pos_x).to_ratatui_color();
- CommitInfo::new(commit, refs, graph_color)
+ CommitInfo::new(commit.clone(), refs, graph_color)
})
.collect();
let graph_cell_width = match cell_width_type {
@@ -101,8 +107,8 @@ impl<'a> App<'a> {
);
if let InitialSelection::Head = initial_selection {
match repository.head() {
- Head::Branch { name } => commit_list_state.select_ref(name),
- Head::Detached { target } => commit_list_state.select_commit_hash(target),
+ Head::Branch { name } => commit_list_state.select_ref(&name),
+ Head::Detached { target } => commit_list_state.select_commit_hash(&target),
}
}
let view = View::of_list(commit_list_state, ui_config, color_theme, tx.clone());
@@ -110,11 +116,14 @@ impl<'a> App<'a> {
Self {
repository,
status_line: StatusLine::None,
+ pending_message: None,
view,
keybind,
core_config,
ui_config,
color_theme,
+ graph_color_set,
+ cell_width_type,
image_protocol,
tx,
numeric_prefix: String::new(),
@@ -133,6 +142,19 @@ impl App<'_> {
terminal.draw(|f| self.render(f))?;
match rx.recv() {
AppEvent::Key(key) => {
+ // Handle pending overlay - Esc hides it
+ if self.pending_message.is_some() {
+ if let Some(UserEvent::Cancel) = self.keybind.get(&key) {
+ self.pending_message = None;
+ self.tx.send(AppEvent::NotifyInfo(
+ "Operation continues in background".into(),
+ ));
+ continue;
+ }
+ // Block other keys while pending
+ continue;
+ }
+
match self.status_line {
StatusLine::None | StatusLine::Input(_, _, _) => {
// do nothing
@@ -171,10 +193,11 @@ impl App<'_> {
self.numeric_prefix.clear();
}
None => {
- if let StatusLine::Input(_, _, _) = self.status_line {
+ let is_input_mode =
+ matches!(self.status_line, StatusLine::Input(_, _, _))
+ || matches!(self.view, View::CreateTag(_));
+ if is_input_mode {
// In input mode, pass all key events to the view
- // fixme: currently, the only thing that processes key_event is searching the list,
- // so this probably works, but it's not the right process...
self.numeric_prefix.clear();
self.view.handle_event(
UserEventWithCount::from_event(UserEvent::Unknown),
@@ -221,6 +244,39 @@ impl App<'_> {
AppEvent::CloseRefs => {
self.close_refs();
}
+ AppEvent::OpenCreateTag => {
+ self.open_create_tag();
+ }
+ AppEvent::CloseCreateTag => {
+ self.close_create_tag();
+ }
+ AppEvent::AddTagToCommit {
+ commit_hash,
+ tag_name,
+ } => {
+ self.add_tag_to_commit(&commit_hash, &tag_name);
+ }
+ AppEvent::OpenDeleteTag => {
+ self.open_delete_tag();
+ }
+ AppEvent::CloseDeleteTag => {
+ self.close_delete_tag();
+ }
+ AppEvent::RemoveTagFromCommit {
+ commit_hash,
+ tag_name,
+ } => {
+ self.remove_tag_from_commit(&commit_hash, &tag_name);
+ }
+ AppEvent::OpenDeleteRef { ref_name, ref_type } => {
+ self.open_delete_ref(ref_name, ref_type);
+ }
+ AppEvent::CloseDeleteRef => {
+ self.close_delete_ref();
+ }
+ AppEvent::RemoveRefFromList { ref_name } => {
+ self.remove_ref_from_list(&ref_name);
+ }
AppEvent::OpenHelp => {
self.open_help();
}
@@ -260,6 +316,15 @@ impl App<'_> {
AppEvent::NotifyError(msg) => {
self.error_notification(msg);
}
+ AppEvent::ShowPendingOverlay { message } => {
+ self.pending_message = Some(message);
+ }
+ AppEvent::HidePendingOverlay => {
+ self.pending_message = None;
+ }
+ AppEvent::Refresh => {
+ self.refresh();
+ }
}
}
}
@@ -277,6 +342,11 @@ impl App<'_> {
self.view.render(f, view_area);
self.render_status_line(f, status_line_area);
+
+ if let Some(message) = &self.pending_message {
+ let overlay = PendingOverlay::new(message, self.color_theme);
+ f.render_widget(overlay, f.area());
+ }
}
}
@@ -285,7 +355,7 @@ impl App<'_> {
let text: Line = match &self.status_line {
StatusLine::None => {
if self.numeric_prefix.is_empty() {
- Line::raw("")
+ self.build_hotkey_hints()
} else {
Line::raw(self.numeric_prefix.as_str())
.fg(self.color_theme.status_input_transient_fg)
@@ -339,6 +409,56 @@ impl App<'_> {
}
}
}
+
+ fn build_hotkey_hints(&self) -> Line<'static> {
+ let hints: Vec<(UserEvent, &str)> = match &self.view {
+ View::List(_) => vec![
+ (UserEvent::Search, "search"),
+ (UserEvent::Filter, "filter"),
+ (UserEvent::IgnoreCaseToggle, "case"),
+ (UserEvent::CreateTag, "tag"),
+ (UserEvent::RefListToggle, "refs"),
+ (UserEvent::Refresh, "refresh"),
+ (UserEvent::HelpToggle, "help"),
+ ],
+ View::Detail(_) => vec![
+ (UserEvent::ShortCopy, "copy"),
+ (UserEvent::Close, "close"),
+ (UserEvent::HelpToggle, "help"),
+ ],
+ View::Refs(_) => vec![
+ (UserEvent::ShortCopy, "copy"),
+ (UserEvent::UserCommandViewToggle(1), "delete"),
+ (UserEvent::Close, "close"),
+ (UserEvent::HelpToggle, "help"),
+ ],
+ View::CreateTag(_) | View::DeleteTag(_) | View::DeleteRef(_) => vec![
+ (UserEvent::Confirm, "confirm"),
+ (UserEvent::Cancel, "cancel"),
+ ],
+ View::Help(_) => vec![(UserEvent::Close, "close")],
+ _ => vec![],
+ };
+
+ let key_fg = self.color_theme.help_key_fg;
+ let desc_fg = self.color_theme.status_input_transient_fg;
+
+ let mut spans: Vec> = Vec::new();
+ for (i, (event, desc)) in hints.iter().enumerate() {
+ if let Some(key) = self.keybind.keys_for_event(*event).first() {
+ if i > 0 {
+ spans.push(Span::raw(" "));
+ }
+ spans.push(Span::styled(key.clone(), Style::default().fg(key_fg)));
+ spans.push(Span::raw(" "));
+ spans.push(Span::styled(
+ (*desc).to_string(),
+ Style::default().fg(desc_fg),
+ ));
+ }
+ }
+ Line::from(spans)
+ }
}
impl App<'_> {
@@ -348,15 +468,12 @@ impl App<'_> {
fn open_detail(&mut self) {
if let View::List(ref mut view) = self.view {
- let commit_list_state = view.take_list_state();
+ let Some(commit_list_state) = view.take_list_state() else {
+ return;
+ };
let selected = commit_list_state.selected_commit_hash().clone();
let (commit, changes) = self.repository.commit_detail(&selected);
- let refs = self
- .repository
- .refs(&selected)
- .into_iter()
- .cloned()
- .collect();
+ let refs = self.repository.refs(&selected);
self.view = View::of_detail(
commit_list_state,
commit,
@@ -372,7 +489,9 @@ impl App<'_> {
fn close_detail(&mut self) {
if let View::Detail(ref mut view) = self.view {
- let commit_list_state = view.take_list_state();
+ let Some(commit_list_state) = view.take_list_state() else {
+ return;
+ };
self.view = View::of_list(
commit_list_state,
self.ui_config,
@@ -390,7 +509,9 @@ impl App<'_> {
fn open_user_command(&mut self, user_command_number: usize) {
if let View::List(ref mut view) = self.view {
- let commit_list_state = view.take_list_state();
+ let Some(commit_list_state) = view.take_list_state() else {
+ return;
+ };
let selected = commit_list_state.selected_commit_hash().clone();
let (commit, _) = self.repository.commit_detail(&selected);
self.view = View::of_user_command_from_list(
@@ -405,7 +526,9 @@ impl App<'_> {
self.tx.clone(),
);
} else if let View::Detail(ref mut view) = self.view {
- let commit_list_state = view.take_list_state();
+ let Some(commit_list_state) = view.take_list_state() else {
+ return;
+ };
let selected = commit_list_state.selected_commit_hash().clone();
let (commit, _) = self.repository.commit_detail(&selected);
self.view = View::of_user_command_from_detail(
@@ -420,10 +543,13 @@ impl App<'_> {
self.tx.clone(),
);
} else if let View::UserCommand(ref mut view) = self.view {
- let commit_list_state = view.take_list_state();
+ let before_view_is_list = view.before_view_is_list();
+ let Some(commit_list_state) = view.take_list_state() else {
+ return;
+ };
let selected = commit_list_state.selected_commit_hash().clone();
let (commit, _) = self.repository.commit_detail(&selected);
- if view.before_view_is_list() {
+ if before_view_is_list {
self.view = View::of_user_command_from_list(
commit_list_state,
commit,
@@ -453,16 +579,14 @@ impl App<'_> {
fn close_user_command(&mut self) {
if let View::UserCommand(ref mut view) = self.view {
- let commit_list_state = view.take_list_state();
+ let before_view_is_list = view.before_view_is_list();
+ let Some(commit_list_state) = view.take_list_state() else {
+ return;
+ };
let selected = commit_list_state.selected_commit_hash().clone();
let (commit, changes) = self.repository.commit_detail(&selected);
- let refs = self
- .repository
- .refs(&selected)
- .into_iter()
- .cloned()
- .collect();
- if view.before_view_is_list() {
+ let refs = self.repository.refs(&selected);
+ if before_view_is_list {
self.view = View::of_list(
commit_list_state,
self.ui_config,
@@ -492,8 +616,10 @@ impl App<'_> {
fn open_refs(&mut self) {
if let View::List(ref mut view) = self.view {
- let commit_list_state = view.take_list_state();
- let refs = self.repository.all_refs().into_iter().cloned().collect();
+ let Some(commit_list_state) = view.take_list_state() else {
+ return;
+ };
+ let refs = self.get_current_refs();
self.view = View::of_refs(
commit_list_state,
refs,
@@ -504,9 +630,110 @@ impl App<'_> {
}
}
+ fn get_current_refs(&self) -> Vec> {
+ self.repository.all_refs()
+ }
+
fn close_refs(&mut self) {
if let View::Refs(ref mut view) = self.view {
- let commit_list_state = view.take_list_state();
+ let Some(commit_list_state) = view.take_list_state() else {
+ return;
+ };
+ self.view = View::of_list(
+ commit_list_state,
+ self.ui_config,
+ self.color_theme,
+ self.tx.clone(),
+ );
+ }
+ }
+
+ fn open_create_tag(&mut self) {
+ if let View::List(ref mut view) = self.view {
+ let Some(commit_list_state) = view.take_list_state() else {
+ return;
+ };
+ let commit_hash = commit_list_state.selected_commit_hash().clone();
+ self.view = View::of_create_tag(
+ commit_list_state,
+ commit_hash,
+ self.repository.path().to_path_buf(),
+ self.ui_config,
+ self.color_theme,
+ self.tx.clone(),
+ );
+ }
+ }
+
+ fn close_create_tag(&mut self) {
+ if let View::CreateTag(ref mut view) = self.view {
+ let Some(commit_list_state) = view.take_list_state() else {
+ return;
+ };
+ self.view = View::of_list(
+ commit_list_state,
+ self.ui_config,
+ self.color_theme,
+ self.tx.clone(),
+ );
+ }
+ }
+
+ fn add_tag_to_commit(&mut self, commit_hash: &CommitHash, tag_name: &str) {
+ let new_tag = Ref::Tag {
+ name: tag_name.to_string(),
+ target: commit_hash.clone(),
+ };
+
+ self.repository.add_ref(new_tag.clone());
+
+ match &mut self.view {
+ View::List(view) => {
+ view.add_ref_to_commit(commit_hash, new_tag);
+ }
+ View::CreateTag(view) => {
+ view.add_ref_to_commit(commit_hash, new_tag);
+ }
+ _ => {}
+ }
+ }
+
+ fn open_delete_tag(&mut self) {
+ if let View::List(ref mut view) = self.view {
+ let Some(commit_list_state) = view.take_list_state() else {
+ return;
+ };
+ let commit_hash = commit_list_state.selected_commit_hash().clone();
+ let tags = commit_list_state.selected_commit_refs();
+ let has_tags = tags.iter().any(|r| matches!(r.as_ref(), Ref::Tag { .. }));
+ if !has_tags {
+ self.view = View::of_list(
+ commit_list_state,
+ self.ui_config,
+ self.color_theme,
+ self.tx.clone(),
+ );
+ self.tx
+ .send(AppEvent::NotifyWarn("No tags on this commit".into()));
+ return;
+ }
+ self.view = View::of_delete_tag(
+ commit_list_state,
+ commit_hash,
+ tags,
+ self.repository.path().to_path_buf(),
+ self.ui_config,
+ self.color_theme,
+ self.tx.clone(),
+ );
+ }
+ }
+
+ fn close_delete_tag(&mut self) {
+ if let View::DeleteTag(ref mut view) = self.view {
+ let Some(commit_list_state) = view.take_list_state() else {
+ return;
+ };
self.view = View::of_list(
commit_list_state,
self.ui_config,
@@ -516,6 +743,73 @@ impl App<'_> {
}
}
+ fn remove_tag_from_commit(&mut self, commit_hash: &CommitHash, tag_name: &str) {
+ self.repository.remove_ref(tag_name);
+
+ match &mut self.view {
+ View::List(view) => {
+ view.remove_ref_from_commit(commit_hash, tag_name);
+ }
+ View::DeleteTag(view) => {
+ view.remove_ref_from_commit(commit_hash, tag_name);
+ }
+ _ => {}
+ }
+ }
+
+ fn open_delete_ref(&mut self, ref_name: String, ref_type: RefType) {
+ if let View::Refs(ref mut view) = self.view {
+ let Some(commit_list_state) = view.take_list_state() else {
+ return;
+ };
+ let ref_list_state = view.take_ref_list_state();
+ let refs = view.take_refs();
+ self.view = View::of_delete_ref(
+ commit_list_state,
+ ref_list_state,
+ refs,
+ self.repository.path().to_path_buf(),
+ ref_name,
+ ref_type,
+ self.ui_config,
+ self.color_theme,
+ self.tx.clone(),
+ );
+ }
+ }
+
+ fn close_delete_ref(&mut self) {
+ if let View::DeleteRef(ref mut view) = self.view {
+ let Some(commit_list_state) = view.take_list_state() else {
+ return;
+ };
+ let ref_list_state = view.take_ref_list_state();
+ let refs = view.take_refs();
+ self.view = View::of_refs_with_state(
+ commit_list_state,
+ ref_list_state,
+ refs,
+ self.ui_config,
+ self.color_theme,
+ self.tx.clone(),
+ );
+ }
+ }
+
+ fn remove_ref_from_list(&mut self, ref_name: &str) {
+ self.repository.remove_ref(ref_name);
+
+ match &mut self.view {
+ View::Refs(view) => {
+ view.remove_ref(ref_name);
+ }
+ View::DeleteRef(view) => {
+ view.remove_ref(ref_name);
+ }
+ _ => {}
+ }
+ }
+
fn open_help(&mut self) {
let before_view = std::mem::take(&mut self.view);
self.view = View::of_help(
@@ -542,25 +836,25 @@ impl App<'_> {
fn select_older_commit(&mut self) {
if let View::Detail(ref mut view) = self.view {
- view.select_older_commit(self.repository);
+ view.select_older_commit(&self.repository);
} else if let View::UserCommand(ref mut view) = self.view {
- view.select_older_commit(self.repository, self.view_area);
+ view.select_older_commit(&self.repository, self.view_area);
}
}
fn select_newer_commit(&mut self) {
if let View::Detail(ref mut view) = self.view {
- view.select_newer_commit(self.repository);
+ view.select_newer_commit(&self.repository);
} else if let View::UserCommand(ref mut view) = self.view {
- view.select_newer_commit(self.repository, self.view_area);
+ view.select_newer_commit(&self.repository, self.view_area);
}
}
fn select_parent_commit(&mut self) {
if let View::Detail(ref mut view) = self.view {
- view.select_parent_commit(self.repository);
+ view.select_parent_commit(&self.repository);
} else if let View::UserCommand(ref mut view) = self.view {
- view.select_parent_commit(self.repository, self.view_area);
+ view.select_parent_commit(&self.repository, self.view_area);
}
}
@@ -604,6 +898,78 @@ impl App<'_> {
}
}
}
+
+ fn refresh(&mut self) {
+ // Reload repository from disk
+ let sort = self.repository.sort_order();
+ let path = self.repository.path().to_path_buf();
+
+ let repository = match Repository::load(&path, sort) {
+ Ok(repo) => repo,
+ Err(e) => {
+ self.tx
+ .send(AppEvent::NotifyError(format!("Refresh failed: {}", e)));
+ return;
+ }
+ };
+
+ // Recalculate graph
+ let graph = Rc::new(calc_graph(&repository));
+
+ // Create new graph image manager
+ let graph_image_manager = GraphImageManager::new(
+ Rc::clone(&graph),
+ self.graph_color_set,
+ self.cell_width_type,
+ self.image_protocol,
+ false, // don't preload
+ );
+
+ // Build new commit list state
+ let mut ref_name_to_commit_index_map = HashMap::new();
+ let commits = graph
+ .commits
+ .iter()
+ .enumerate()
+ .map(|(i, commit)| {
+ let refs = repository.refs(&commit.commit_hash);
+ for r in &refs {
+ ref_name_to_commit_index_map.insert(r.name().to_string(), i);
+ }
+ let (pos_x, _) = graph.commit_pos_map[&commit.commit_hash];
+ let graph_color = self.graph_color_set.get(pos_x).to_ratatui_color();
+ CommitInfo::new(commit.clone(), refs, graph_color)
+ })
+ .collect();
+
+ let graph_cell_width = match self.cell_width_type {
+ CellWidthType::Double => (graph.max_pos_x + 1) as u16 * 2,
+ CellWidthType::Single => (graph.max_pos_x + 1) as u16,
+ };
+
+ let head = repository.head();
+ let commit_list_state = CommitListState::new(
+ commits,
+ graph_image_manager,
+ graph_cell_width,
+ head,
+ ref_name_to_commit_index_map,
+ self.core_config.search.ignore_case,
+ self.core_config.search.fuzzy,
+ );
+
+ // Update app state
+ self.repository = repository;
+ self.view = View::of_list(
+ commit_list_state,
+ self.ui_config,
+ self.color_theme,
+ self.tx.clone(),
+ );
+
+ self.tx
+ .send(AppEvent::NotifySuccess("Repository refreshed".into()));
+ }
}
fn process_numeric_prefix(
diff --git a/src/event.rs b/src/event.rs
index 9584934..4b0aa4a 100644
--- a/src/event.rs
+++ b/src/event.rs
@@ -22,19 +22,47 @@ pub enum AppEvent {
ClearUserCommand,
OpenRefs,
CloseRefs,
+ OpenCreateTag,
+ CloseCreateTag,
+ AddTagToCommit {
+ commit_hash: crate::git::CommitHash,
+ tag_name: String,
+ },
+ OpenDeleteTag,
+ CloseDeleteTag,
+ RemoveTagFromCommit {
+ commit_hash: crate::git::CommitHash,
+ tag_name: String,
+ },
+ OpenDeleteRef {
+ ref_name: String,
+ ref_type: crate::git::RefType,
+ },
+ CloseDeleteRef,
+ RemoveRefFromList {
+ ref_name: String,
+ },
OpenHelp,
CloseHelp,
ClearHelp,
SelectNewerCommit,
SelectOlderCommit,
SelectParentCommit,
- CopyToClipboard { name: String, value: String },
+ CopyToClipboard {
+ name: String,
+ value: String,
+ },
ClearStatusLine,
UpdateStatusInput(String, Option, Option),
NotifyInfo(String),
NotifySuccess(String),
NotifyWarn(String),
NotifyError(String),
+ ShowPendingOverlay {
+ message: String,
+ },
+ HidePendingOverlay,
+ Refresh,
}
#[derive(Clone)]
@@ -44,7 +72,7 @@ pub struct Sender {
impl Sender {
pub fn send(&self, event: AppEvent) {
- self.tx.send(event).unwrap();
+ let _ = self.tx.send(event);
}
}
@@ -60,7 +88,7 @@ pub struct Receiver {
impl Receiver {
pub fn recv(&self) -> AppEvent {
- self.rx.recv().unwrap()
+ self.rx.recv().unwrap_or(AppEvent::Quit)
}
}
@@ -121,11 +149,15 @@ pub enum UserEvent {
Confirm,
RefListToggle,
Search,
+ Filter,
UserCommandViewToggle(usize),
IgnoreCaseToggle,
FuzzyToggle,
ShortCopy,
FullCopy,
+ CreateTag,
+ DeleteTag,
+ Refresh,
Unknown,
}
@@ -184,10 +216,14 @@ impl<'de> Deserialize<'de> for UserEvent {
"confirm" => Ok(UserEvent::Confirm),
"ref_list_toggle" => Ok(UserEvent::RefListToggle),
"search" => Ok(UserEvent::Search),
+ "filter" => Ok(UserEvent::Filter),
"ignore_case_toggle" => Ok(UserEvent::IgnoreCaseToggle),
"fuzzy_toggle" => Ok(UserEvent::FuzzyToggle),
"short_copy" => Ok(UserEvent::ShortCopy),
"full_copy" => Ok(UserEvent::FullCopy),
+ "create_tag" => Ok(UserEvent::CreateTag),
+ "delete_tag" => Ok(UserEvent::DeleteTag),
+ "refresh" => Ok(UserEvent::Refresh),
_ => {
let msg = format!("Unknown user event: {}", value);
Err(de::Error::custom(msg))
diff --git a/src/git.rs b/src/git.rs
index 824bb77..ba4ef84 100644
--- a/src/git.rs
+++ b/src/git.rs
@@ -4,14 +4,17 @@ use std::{
io::{BufRead, BufReader},
path::{Path, PathBuf},
process::{Command, Stdio},
+ rc::Rc,
+ sync::Arc,
};
use chrono::{DateTime, FixedOffset};
use crate::Result;
-#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
-pub struct CommitHash(String);
+/// Arc for cheap cloning and Send trait (required by mpsc::Sender)
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct CommitHash(Arc);
impl CommitHash {
pub fn as_short_hash(&self) -> String {
@@ -23,9 +26,15 @@ impl CommitHash {
}
}
+impl Default for CommitHash {
+ fn default() -> Self {
+ Self(Arc::from(""))
+ }
+}
+
impl From<&str> for CommitHash {
fn from(s: &str) -> Self {
- Self(s.to_string())
+ Self(Arc::from(s))
}
}
@@ -51,6 +60,13 @@ pub struct Commit {
pub commit_type: CommitType,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RefType {
+ Tag,
+ Branch,
+ RemoteBranch,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Ref {
Tag {
@@ -104,14 +120,15 @@ pub enum SortCommit {
Topological,
}
-type CommitMap = HashMap;
+type CommitMap = HashMap>;
type CommitsMap = HashMap>;
-type RefMap = HashMap>;
+type RefMap = HashMap>>;
#[derive(Debug)]
pub struct Repository {
path: PathBuf,
+ sort: SortCommit,
commit_map: CommitMap,
parents_map: CommitsMap,
@@ -140,42 +157,23 @@ impl Repository {
let stash_ref_map = load_stashes_as_refs(path);
merge_ref_maps(&mut ref_map, stash_ref_map);
- Ok(Self::new(
- path.to_path_buf(),
+ Ok(Self {
+ path: path.to_path_buf(),
+ sort,
commit_map,
parents_map,
children_map,
ref_map,
head,
commit_hashes,
- ))
- }
-
- pub fn new(
- path: PathBuf,
- commit_map: CommitMap,
- parents_map: CommitsMap,
- children_map: CommitsMap,
- ref_map: RefMap,
- head: Head,
- commit_hashes: Vec,
- ) -> Self {
- Self {
- path,
- commit_map,
- parents_map,
- children_map,
- ref_map,
- head,
- commit_hashes,
- }
+ })
}
- pub fn commit(&self, commit_hash: &CommitHash) -> Option<&Commit> {
- self.commit_map.get(commit_hash)
+ pub fn commit(&self, commit_hash: &CommitHash) -> Option> {
+ self.commit_map.get(commit_hash).cloned()
}
- pub fn all_commits(&self) -> Vec<&Commit> {
+ pub fn all_commits(&self) -> Vec> {
self.commit_hashes
.iter()
.filter_map(|hash| self.commit(hash))
@@ -196,23 +194,24 @@ impl Repository {
.unwrap_or_default()
}
- pub fn refs(&self, commit_hash: &CommitHash) -> Vec<&Ref> {
- self.ref_map
- .get(commit_hash)
- .map(|refs| refs.iter().collect::>())
- .unwrap_or_default()
+ pub fn refs(&self, commit_hash: &CommitHash) -> Vec> {
+ self.ref_map.get(commit_hash).cloned().unwrap_or_default()
+ }
+
+ pub fn all_refs(&self) -> Vec> {
+ self.ref_map.values().flatten().cloned().collect()
}
- pub fn all_refs(&self) -> Vec<&Ref> {
- self.ref_map.values().flatten().collect()
+ pub fn head(&self) -> Head {
+ self.head.clone()
}
- pub fn head(&self) -> &Head {
- &self.head
+ pub fn path(&self) -> &Path {
+ &self.path
}
- pub fn commit_detail(&self, commit_hash: &CommitHash) -> (Commit, Vec) {
- let commit = self.commit(commit_hash).unwrap().clone();
+ pub fn commit_detail(&self, commit_hash: &CommitHash) -> (Rc, Vec) {
+ let commit = self.commit(commit_hash).unwrap();
let changes = if commit.parent_commit_hashes.is_empty() {
get_initial_commit_additions(&self.path, commit_hash)
} else {
@@ -220,6 +219,22 @@ impl Repository {
};
(commit, changes)
}
+
+ pub fn sort_order(&self) -> SortCommit {
+ self.sort
+ }
+
+ pub fn add_ref(&mut self, new_ref: Ref) {
+ let target = new_ref.target().clone();
+ let rc_ref = Rc::new(new_ref);
+ self.ref_map.entry(target).or_default().push(rc_ref);
+ }
+
+ pub fn remove_ref(&mut self, ref_name: &str) {
+ for refs in self.ref_map.values_mut() {
+ refs.retain(|r| r.name() != ref_name);
+ }
+ }
}
fn check_git_repository(path: &Path) -> Result<()> {
@@ -231,23 +246,23 @@ fn check_git_repository(path: &Path) -> Result<()> {
}
fn is_inside_work_tree(path: &Path) -> bool {
- let output = Command::new("git")
+ Command::new("git")
.arg("rev-parse")
.arg("--is-inside-work-tree")
.current_dir(path)
.output()
- .unwrap();
- output.status.success() && output.stdout == b"true\n"
+ .map(|o| o.status.success() && o.stdout == b"true\n")
+ .unwrap_or(false)
}
fn is_bare_repository(path: &Path) -> bool {
- let output = Command::new("git")
+ Command::new("git")
.arg("rev-parse")
.arg("--is-bare-repository")
.current_dir(path)
.output()
- .unwrap();
- output.status.success() && output.stdout == b"true\n"
+ .map(|o| o.status.success() && o.stdout == b"true\n")
+ .unwrap_or(false)
}
fn load_all_commits(path: &Path, sort: SortCommit, stashes: &[Commit]) -> Vec {
@@ -403,7 +418,7 @@ fn build_commits_maps(commits: &Vec) -> (CommitsMap, CommitsMap) {
fn to_commit_map(commits: Vec) -> CommitMap {
commits
.into_iter()
- .map(|commit| (commit.commit_hash.clone(), commit))
+ .map(|commit| (commit.commit_hash.clone(), Rc::new(commit)))
.collect()
}
@@ -467,7 +482,7 @@ fn load_refs(path: &Path) -> (RefMap, Head) {
})
};
} else if let Some(r) = parse_branch_refs(hash, refs) {
- ref_map.entry(hash.into()).or_default().push(r);
+ ref_map.entry(hash.into()).or_default().push(Rc::new(r));
} else if let Some(r) = parse_tag_refs(hash, refs) {
// if annotated tag exists, it will be overwritten by the following line of the same tag
// this will make the tag point to the commit that the annotated tag points to
@@ -478,7 +493,10 @@ fn load_refs(path: &Path) -> (RefMap, Head) {
let head = head.expect("HEAD not found in `git show-ref --head` output");
for tag in tag_map.into_values() {
- ref_map.entry(tag.target().clone()).or_default().push(tag);
+ ref_map
+ .entry(tag.target().clone())
+ .or_default()
+ .push(Rc::new(tag));
}
ref_map.values_mut().for_each(|refs| refs.sort());
@@ -524,7 +542,7 @@ fn load_stashes_as_refs(path: &Path) -> RefMap {
target: hash.into(),
};
- ref_map.entry(hash.into()).or_default().push(r);
+ ref_map.entry(hash.into()).or_default().push(Rc::new(r));
}
cmd.wait().unwrap();
@@ -606,8 +624,8 @@ pub fn get_diff_summary(path: &Path, commit_hash: &CommitHash) -> Vec Ve
.arg("ls-tree")
.arg("--name-status")
.arg("-r") // the empty tree hash
- .arg(&commit_hash.0)
+ .arg(commit_hash.as_str())
.current_dir(path)
.stdout(Stdio::piped())
.stderr(Stdio::null())
@@ -674,3 +692,176 @@ pub fn get_initial_commit_additions(path: &Path, commit_hash: &CommitHash) -> Ve
changes
}
+
+/// Validates a git ref name according to git-check-ref-format rules.
+/// Returns Ok(()) if valid, Err with message if invalid.
+fn validate_ref_name(name: &str) -> std::result::Result<(), String> {
+ if name.is_empty() {
+ return Err("Ref name cannot be empty".into());
+ }
+ if name.starts_with('-') {
+ return Err("Ref name cannot start with '-'".into());
+ }
+ if name.starts_with('.') || name.ends_with('.') {
+ return Err("Ref name cannot start or end with '.'".into());
+ }
+ if name.contains("..") {
+ return Err("Ref name cannot contain '..'".into());
+ }
+ if name.contains("//") {
+ return Err("Ref name cannot contain '//'".into());
+ }
+ if name.ends_with('/') {
+ return Err("Ref name cannot end with '/'".into());
+ }
+ if name.contains(|c: char| c.is_ascii_control() || c == ' ' || c == '~' || c == '^' || c == ':')
+ {
+ return Err("Ref name contains invalid characters".into());
+ }
+ if name.ends_with(".lock") {
+ return Err("Ref name cannot end with '.lock'".into());
+ }
+ if name.contains("@{") {
+ return Err("Ref name cannot contain '@{'".into());
+ }
+ if name == "@" {
+ return Err("Ref name cannot be '@'".into());
+ }
+ if name.contains('\\') {
+ return Err("Ref name cannot contain '\\'".into());
+ }
+ Ok(())
+}
+
+pub fn create_tag(
+ path: &Path,
+ name: &str,
+ commit_hash: &CommitHash,
+ message: Option<&str>,
+) -> std::result::Result<(), String> {
+ validate_ref_name(name)?;
+ let mut cmd = Command::new("git");
+ cmd.arg("tag");
+ if let Some(msg) = message {
+ if !msg.is_empty() {
+ cmd.arg("-a").arg("-m").arg(msg);
+ }
+ }
+ cmd.arg(name).arg(commit_hash.as_str()).current_dir(path);
+
+ let output = cmd
+ .output()
+ .map_err(|e| format!("Failed to execute git tag: {e}"))?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!("Failed to create tag: {stderr}"));
+ }
+ Ok(())
+}
+
+pub fn push_tag(path: &Path, tag_name: &str) -> std::result::Result<(), String> {
+ let output = Command::new("git")
+ .arg("push")
+ .arg("origin")
+ .arg(tag_name)
+ .current_dir(path)
+ .output()
+ .map_err(|e| format!("Failed to execute git push: {e}"))?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!("Failed to push tag: {stderr}"));
+ }
+ Ok(())
+}
+
+pub fn delete_tag(path: &Path, tag_name: &str) -> std::result::Result<(), String> {
+ let output = Command::new("git")
+ .arg("tag")
+ .arg("-d")
+ .arg(tag_name)
+ .current_dir(path)
+ .output()
+ .map_err(|e| format!("Failed to execute git tag -d: {e}"))?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!("Failed to delete tag: {stderr}"));
+ }
+ Ok(())
+}
+
+pub fn delete_remote_tag(path: &Path, tag_name: &str) -> std::result::Result<(), String> {
+ let output = Command::new("git")
+ .arg("push")
+ .arg("origin")
+ .arg("--delete")
+ .arg(tag_name)
+ .current_dir(path)
+ .output()
+ .map_err(|e| format!("Failed to execute git push --delete: {e}"))?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!("Failed to delete remote tag: {stderr}"));
+ }
+ Ok(())
+}
+
+pub fn delete_branch(path: &Path, branch_name: &str) -> std::result::Result<(), String> {
+ let output = Command::new("git")
+ .arg("branch")
+ .arg("-d")
+ .arg(branch_name)
+ .current_dir(path)
+ .output()
+ .map_err(|e| format!("Failed to execute git branch -d: {e}"))?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!("Failed to delete branch: {stderr}"));
+ }
+ Ok(())
+}
+
+pub fn delete_branch_force(path: &Path, branch_name: &str) -> std::result::Result<(), String> {
+ let output = Command::new("git")
+ .arg("branch")
+ .arg("-D")
+ .arg(branch_name)
+ .current_dir(path)
+ .output()
+ .map_err(|e| format!("Failed to execute git branch -D: {e}"))?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!("Failed to force delete branch: {stderr}"));
+ }
+ Ok(())
+}
+
+pub fn delete_remote_branch(path: &Path, branch_name: &str) -> std::result::Result<(), String> {
+ // branch_name for remote branches is like "origin/feature" - we need to split
+ let parts: Vec<&str> = branch_name.splitn(2, '/').collect();
+ if parts.len() != 2 {
+ return Err(format!("Invalid remote branch name format: {branch_name}"));
+ }
+ let remote = parts[0];
+ let branch = parts[1];
+
+ let output = Command::new("git")
+ .arg("push")
+ .arg(remote)
+ .arg("--delete")
+ .arg(branch)
+ .current_dir(path)
+ .output()
+ .map_err(|e| format!("Failed to execute git push --delete: {e}"))?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!("Failed to delete remote branch: {stderr}"));
+ }
+ Ok(())
+}
diff --git a/src/graph/calc.rs b/src/graph/calc.rs
index ec40693..6d8f9ae 100644
--- a/src/graph/calc.rs
+++ b/src/graph/calc.rs
@@ -1,13 +1,15 @@
+use std::rc::Rc;
+
use rustc_hash::FxHashMap;
use crate::git::{Commit, CommitHash, Repository};
-type CommitPosMap<'a> = FxHashMap<&'a CommitHash, (usize, usize)>;
+type CommitPosMap = FxHashMap;
#[derive(Debug)]
-pub struct Graph<'a> {
- pub commits: Vec<&'a Commit>,
- pub commit_pos_map: CommitPosMap<'a>,
+pub struct Graph {
+ pub commits: Vec>,
+ pub commit_pos_map: CommitPosMap,
pub edges: Vec>,
pub max_pos_x: usize,
}
@@ -43,7 +45,7 @@ pub enum EdgeType {
LeftBottom, // ╰
}
-pub fn calc_graph(repository: &Repository) -> Graph<'_> {
+pub fn calc_graph(repository: &Repository) -> Graph {
let commits = repository.all_commits();
let commit_pos_map = calc_commit_positions(&commits, repository);
@@ -57,32 +59,26 @@ pub fn calc_graph(repository: &Repository) -> Graph<'_> {
}
}
-fn calc_commit_positions<'a>(
- commits: &[&'a Commit],
- repository: &'a Repository,
-) -> CommitPosMap<'a> {
+fn calc_commit_positions(commits: &[Rc], repository: &Repository) -> CommitPosMap {
let mut commit_pos_map: CommitPosMap = FxHashMap::default();
- let mut commit_line_state: Vec