Skip to content

Commit f09ddb1

Browse files
committed
refactor(git): async operations and single-pass diff parsing
Replace std::process::Command with tokio::process::Command for all git CLI calls. Reduce N+1 process spawns to 2 by parsing a single unified diff into per-file sections. Pre-fetch file content into HashMaps before sync tree-sitter analysis.
1 parent ab09757 commit f09ddb1

3 files changed

Lines changed: 117 additions & 42 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ clap = { version = "4.5", features = ["derive", "env"] }
1616
clap_complete = "4.5"
1717

1818
# Async runtime
19-
tokio = { version = "1.43", features = ["rt-multi-thread", "macros", "signal", "sync"] }
19+
tokio = { version = "1.43", features = ["rt-multi-thread", "macros", "signal", "sync", "process"] }
2020
tokio-stream = "0.1"
2121
tokio-util = "0.7"
2222

src/app.rs

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
//
33
// SPDX-License-Identifier: GPL-3.0-only
44

5+
use std::collections::HashMap;
6+
use std::io::IsTerminal;
7+
use std::path::PathBuf;
8+
59
use console::style;
610
use dialoguer::Confirm;
7-
use std::io::IsTerminal;
811
use tokio::signal;
912
use tokio::sync::mpsc;
1013
use tokio_util::sync::CancellationToken;
@@ -66,7 +69,7 @@ impl App {
6669
self.print_status("Analyzing staged changes...");
6770

6871
let git = GitService::discover()?;
69-
let changes = git.get_staged_changes(self.config.max_file_lines)?;
72+
let changes = git.get_staged_changes(self.config.max_file_lines).await?;
7073

7174
self.print_info(&format!(
7275
"{} files with changes detected (+{} -{})",
@@ -108,16 +111,29 @@ impl App {
108111
return Err(Error::Cancelled);
109112
}
110113

111-
// Step 3: Analyze code with tree-sitter
114+
// Step 3: Pre-fetch file content and analyze with tree-sitter
112115
self.print_status("Extracting code symbols...");
113116

114117
let mut analyzer = AnalyzerService::new()?;
115118

116-
let git_ref = &git;
119+
// Pre-fetch all file content asynchronously, then pass as sync maps
120+
let file_paths: Vec<PathBuf> = changes.files.iter().map(|f| f.path.clone()).collect();
121+
let mut staged_map: HashMap<PathBuf, String> = HashMap::new();
122+
let mut head_map: HashMap<PathBuf, String> = HashMap::new();
123+
124+
for path in &file_paths {
125+
if let Some(content) = git.get_staged_content(path).await {
126+
staged_map.insert(path.clone(), content);
127+
}
128+
if let Some(content) = git.get_head_content(path).await {
129+
head_map.insert(path.clone(), content);
130+
}
131+
}
132+
117133
let symbols = analyzer.extract_symbols(
118134
&changes.files,
119-
&|path| git_ref.get_staged_content(path),
120-
&|path| git_ref.get_head_content(path),
135+
&|path| staged_map.get(path).cloned(),
136+
&|path| head_map.get(path).cloned(),
121137
);
122138

123139
debug!(count = symbols.len(), "symbols extracted");
@@ -221,7 +237,7 @@ impl App {
221237
}
222238

223239
// Create commit
224-
git.commit(&message)?;
240+
git.commit(&message).await?;
225241

226242
eprintln!("{} Committed!", style("✓").green().bold());
227243

src/services/git.rs

Lines changed: 93 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
//
33
// SPDX-License-Identifier: GPL-3.0-only
44

5+
use std::collections::HashMap;
56
use std::path::{Path, PathBuf};
67

8+
use tokio::process::Command;
9+
710
use crate::domain::{ChangeStatus, DiffStats, FileCategory, FileChange, StagedChanges};
811
use crate::error::{Error, Result};
912

@@ -25,33 +28,52 @@ impl GitService {
2528
}
2629

2730
pub fn check_state(&self) -> Result<()> {
28-
// Check for merge/rebase in progress
2931
let state = self.repo.state();
3032
if matches!(state, Some(gix::state::InProgress::Merge)) {
3133
return Err(Error::MergeInProgress);
3234
}
3335
Ok(())
3436
}
3537

36-
pub fn get_staged_changes(&self, max_file_lines: usize) -> Result<StagedChanges> {
37-
self.check_state()?;
38+
// ─── Async Git Helpers ───
3839

39-
// Use git diff --cached --name-status to get list of staged files
40-
let output = std::process::Command::new("git")
41-
.args(["diff", "--cached", "--name-status", "--no-renames"])
40+
async fn run_git(&self, args: &[&str]) -> Result<String> {
41+
let output = Command::new("git")
42+
.args(args)
4243
.current_dir(&self.work_dir)
43-
.output()?;
44+
.output()
45+
.await?;
4446

4547
if !output.status.success() {
4648
let stderr = String::from_utf8_lossy(&output.stderr);
4749
return Err(Error::Git(stderr.to_string()));
4850
}
4951

52+
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
53+
}
54+
55+
// ─── Staged Changes (Single-Pass Diff) ───
56+
57+
pub async fn get_staged_changes(&self, max_file_lines: usize) -> Result<StagedChanges> {
58+
self.check_state()?;
59+
60+
// Two calls total (down from N+1): name-status + unified diff
61+
let (status_output, diff_output) = tokio::try_join!(
62+
self.run_git(&["diff", "--cached", "--name-status", "--no-renames"]),
63+
self.run_git(&[
64+
"diff",
65+
"--cached",
66+
"--no-ext-diff",
67+
"--unified=3",
68+
"--no-renames"
69+
]),
70+
)?;
71+
72+
let file_diffs = Self::split_unified_diff(&diff_output);
73+
5074
let mut files = Vec::new();
5175
let mut stats = DiffStats::default();
5276

53-
let status_output = String::from_utf8_lossy(&output.stdout);
54-
5577
for line in status_output.lines() {
5678
if line.is_empty() {
5779
continue;
@@ -74,11 +96,14 @@ impl GitService {
7496
let is_binary = Self::is_binary_path(&file_path);
7597

7698
if is_binary {
77-
continue; // Skip binary files
99+
continue;
78100
}
79101

80-
// Get diff content
81-
let diff = self.get_file_diff(&file_path, max_file_lines)?;
102+
let diff = file_diffs
103+
.get(parts[1])
104+
.map(|d| Self::truncate_diff(d, max_file_lines))
105+
.unwrap_or_default();
106+
82107
let (additions, deletions) = Self::count_changes(&diff);
83108

84109
files.push(FileChange {
@@ -103,33 +128,61 @@ impl GitService {
103128
Ok(StagedChanges { files, stats })
104129
}
105130

106-
fn get_file_diff(&self, path: &Path, max_lines: usize) -> Result<String> {
107-
// Use git command for reliable diff output
108-
// --no-ext-diff: don't use external diff tools
109-
// --unified=3: standard 3 lines of context
110-
let output = std::process::Command::new("git")
111-
.args(["diff", "--cached", "--no-ext-diff", "--unified=3", "--"])
112-
.arg(path)
113-
.current_dir(&self.work_dir)
114-
.output()?;
131+
/// Split a unified diff into per-file sections keyed by file path.
132+
fn split_unified_diff(diff: &str) -> HashMap<String, String> {
133+
let mut result = HashMap::new();
134+
let mut current_path: Option<String> = None;
135+
let mut current_lines: Vec<&str> = Vec::new();
115136

116-
if !output.status.success() {
117-
let stderr = String::from_utf8_lossy(&output.stderr);
118-
return Err(Error::Git(stderr.to_string()));
137+
for line in diff.lines() {
138+
if line.starts_with("diff --git ") {
139+
// Save previous file's accumulated diff
140+
if let Some(path) = current_path.take() {
141+
result.insert(path, current_lines.join("\n"));
142+
}
143+
current_lines.clear();
144+
}
145+
146+
// Extract path from +++ header (reliable for added/modified files)
147+
if let Some(path) = line.strip_prefix("+++ b/") {
148+
current_path = Some(path.to_string());
149+
}
150+
// For deleted files, +++ is /dev/null — use --- header instead
151+
if line == "+++ /dev/null" {
152+
if let Some(last_minus) =
153+
current_lines.iter().rev().find(|l| l.starts_with("--- a/"))
154+
{
155+
if let Some(path) = last_minus.strip_prefix("--- a/") {
156+
current_path = Some(path.to_string());
157+
}
158+
}
159+
}
160+
161+
current_lines.push(line);
119162
}
120163

121-
let diff = String::from_utf8_lossy(&output.stdout);
122-
let lines: Vec<&str> = diff.lines().take(max_lines).collect();
164+
// Don't forget the last file
165+
if let Some(path) = current_path {
166+
result.insert(path, current_lines.join("\n"));
167+
}
168+
169+
result
170+
}
123171

124-
Ok(lines.join("\n"))
172+
fn truncate_diff(diff: &str, max_lines: usize) -> String {
173+
let lines: Vec<&str> = diff.lines().take(max_lines).collect();
174+
lines.join("\n")
125175
}
126176

177+
// ─── File Content ───
178+
127179
/// Get staged file content (from index)
128-
pub fn get_staged_content(&self, path: &Path) -> Option<String> {
129-
let output = std::process::Command::new("git")
180+
pub async fn get_staged_content(&self, path: &Path) -> Option<String> {
181+
let output: std::process::Output = Command::new("git")
130182
.args(["show", &format!(":0:{}", path.display())])
131183
.current_dir(&self.work_dir)
132184
.output()
185+
.await
133186
.ok()?;
134187

135188
if output.status.success() {
@@ -140,11 +193,12 @@ impl GitService {
140193
}
141194

142195
/// Get HEAD file content
143-
pub fn get_head_content(&self, path: &Path) -> Option<String> {
144-
let output = std::process::Command::new("git")
196+
pub async fn get_head_content(&self, path: &Path) -> Option<String> {
197+
let output: std::process::Output = Command::new("git")
145198
.args(["show", &format!("HEAD:{}", path.display())])
146199
.current_dir(&self.work_dir)
147200
.output()
201+
.await
148202
.ok()?;
149203

150204
if output.status.success() {
@@ -154,6 +208,8 @@ impl GitService {
154208
}
155209
}
156210

211+
// ─── Diff Parsing ───
212+
157213
fn count_changes(diff: &str) -> (usize, usize) {
158214
let mut additions = 0;
159215
let mut deletions = 0;
@@ -199,11 +255,14 @@ impl GitService {
199255
)
200256
}
201257

202-
pub fn commit(&self, message: &str) -> Result<()> {
203-
let output = std::process::Command::new("git")
258+
// ─── Commit ───
259+
260+
pub async fn commit(&self, message: &str) -> Result<()> {
261+
let output = Command::new("git")
204262
.args(["commit", "-m", message])
205263
.current_dir(&self.work_dir)
206-
.output()?;
264+
.output()
265+
.await?;
207266

208267
if !output.status.success() {
209268
let stderr = String::from_utf8_lossy(&output.stderr);

0 commit comments

Comments
 (0)