Skip to content
This repository was archived by the owner on Oct 19, 2026. It is now read-only.

Commit 7649fe3

Browse files
AllureCurtainbenjamin-747genedna
authored
feat: optimize commit-info interface (#1681)
* feat: optimize commit-info interface Signed-off-by: allure <1550220889@qq.com> * fix: optimize item_to_commit_map and fix error Signed-off-by: allure <1550220889@qq.com> --------- Signed-off-by: allure <1550220889@qq.com> Signed-off-by: Yao <1550220889@qq.com> Co-authored-by: Tianxing Ye <benjamin.747@outlook.com> Co-authored-by: Quanyi Ma <eli@patch.sh>
1 parent 277ce41 commit 7649fe3

3 files changed

Lines changed: 238 additions & 48 deletions

File tree

‎ceres/src/api_service/blame_ops.rs‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -169,13 +169,7 @@ pub async fn get_file_blame<T: ApiHandler + ?Sized>(
169169
));
170170
}
171171

172-
// Normalize path to ensure leading /
173-
let file_path_buf = if file_path.starts_with('/') {
174-
PathBuf::from(file_path)
175-
} else {
176-
PathBuf::from(format!("/{}", file_path))
177-
};
178-
let file_path = file_path_buf.to_string_lossy();
172+
let file_path_buf = PathBuf::from(file_path);
179173

180174
// Create cache context for this blame operation
181175
let mut ctx = BlameContext::new();

‎ceres/src/api_service/history.rs‎

Lines changed: 224 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,49 @@ use std::{
66

77
use git_internal::{
88
errors::GitError,
9+
hash::SHA1,
910
internal::object::{
1011
commit::Commit,
11-
tree::{Tree, TreeItem},
12+
tree::{Tree, TreeItem, TreeItemMode},
1213
},
1314
};
1415

1516
use crate::api_service::ApiHandler;
1617

18+
const MAX_ITERATIONS: usize = 10_000;
19+
20+
/// Parent commit info with pre-computed item hash map for batch lookup.
21+
struct ParentInfo {
22+
commit: Arc<Commit>,
23+
/// Map of item name -> (hash, mode) for quick TREESAME check
24+
item_hashes: HashMap<String, (SHA1, TreeItemMode)>,
25+
}
26+
27+
/// State for tracking a single item's traversal through commit history.
28+
/// Used by the batch traversal algorithm in `item_to_commit_map`.
29+
struct ItemTraversalState {
30+
/// The tree item being tracked
31+
item: TreeItem,
32+
/// Current commit being examined for this item
33+
current_commit_id: SHA1,
34+
/// Hash of the item in the current commit
35+
current_hash: SHA1,
36+
/// Whether this item's last modification has been determined
37+
determined: bool,
38+
/// The commit where this item was last modified (set when determined)
39+
result_commit: Option<Commit>,
40+
}
41+
1742
/// Builds a mapping from each `TreeItem` under a given path to the commit
1843
/// where that item was last modified.
1944
///
20-
/// This function retrieves the tree corresponding to the given `path`, then for each entry
21-
/// (file or subdirectory) in that tree, it traverses the commit history backwards to find
22-
/// the most recent commit where the item's hash changed (i.e., was modified).
45+
/// This function retrieves the tree corresponding to the given `path`, then uses a
46+
/// batch traversal algorithm to find the most recent commit where each item's hash
47+
/// changed (i.e., was modified).
2348
///
2449
/// If a `ref` (reference, such as a tag) is provided, traversal starts from the commit
2550
/// that the reference points to. Otherwise, it starts from the repository's root commit.
2651
///
27-
/// Internally, it leverages [`traverse_commit_history_for_last_modification`] to perform
28-
/// the traversal and uses a shared [`GitObjectCache`] to minimize redundant commit/tree lookups.
29-
///
3052
/// # Arguments
3153
/// - `path`: The path to the target directory or subtree to analyze.
3254
/// - `reference`: Optional reference (e.g., tag name) to start traversal from.
@@ -43,21 +65,26 @@ use crate::api_service::ApiHandler;
4365
/// # Algorithm
4466
/// 1. Resolve the starting commit (from `reference` if provided, otherwise root commit).
4567
/// 2. Resolve the tree at the given `path` at the starting commit.
46-
/// 3. For each entry (`TreeItem`) in the resolved tree:
47-
/// - Call [`traverse_commit_history_for_last_modification`] to find the most recent commit
48-
/// where that item's hash changed, starting from the resolved commit and traversing backwards.
49-
/// - Store the mapping in the result map.
50-
/// 4. Return the complete mapping.
68+
/// 3. Initialize traversal state for all items, grouping them by current commit.
69+
/// 4. For each commit group:
70+
/// - Load parent trees once (shared by all items in the group).
71+
/// - For each item, check if any parent has TREESAME content (same hash and mode).
72+
/// - If TREESAME: continue traversing to that parent.
73+
/// - If no TREESAME parent: this commit modified the item.
74+
/// 5. Return the complete mapping.
5175
///
5276
/// # Performance Notes
53-
/// - Uses a shared `Arc<Mutex<GitObjectCache>>` for caching to avoid redundant
54-
/// lookups of commits and trees.
55-
/// - Each item is processed sequentially, so total runtime scales linearly
56-
/// with the number of items in the directory.
77+
/// - Uses batch processing to group items by their current commit, reducing redundant
78+
/// parent tree loads when multiple items are at the same commit.
79+
/// - Uses a commit cache to avoid reloading the same commits.
80+
/// - Uses TREESAME pruning similar to `git log --follow` for efficiency.
81+
///
82+
/// # Limitations
83+
/// - If `MAX_ITERATIONS` is exceeded, remaining undetermined items fall back to
84+
/// the start commit.
5785
///
5886
/// # See Also
59-
/// - [`traverse_commit_history_for_last_modification`] — for the logic of commit traversal
60-
/// and last modification detection.
87+
/// - [`traverse_commit_history_for_last_modification`] — for single-item traversal.
6188
pub async fn item_to_commit_map<T: ApiHandler + ?Sized>(
6289
handler: &T,
6390
path: PathBuf,
@@ -68,7 +95,6 @@ pub async fn item_to_commit_map<T: ApiHandler + ?Sized>(
6895
crate::api_service::commit_ops::resolve_start_commit(handler, reference).await?;
6996

7097
// Get the tree at the specified path from the resolved start commit
71-
// Use the start commit's tree to ensure consistency
7298
let start_tree = handler
7399
.get_tree_by_hash(&start_commit_arc.tree_id.to_string())
74100
.await?;
@@ -78,19 +104,186 @@ pub async fn item_to_commit_map<T: ApiHandler + ?Sized>(
78104
return Ok(HashMap::new());
79105
};
80106

81-
// For each item in the tree, traverse commit history to find its last modification
82107
let tree_items = tree.tree_items.clone();
83-
let mut result = HashMap::with_capacity(tree_items.len());
84-
85-
for item in tree_items {
86-
let commit = traverse_commit_history_for_last_modification(
87-
handler,
88-
&path,
89-
start_commit_arc.clone(),
90-
&item,
91-
)
92-
.await?;
93-
result.insert(item, Some(commit));
108+
let item_count = tree_items.len();
109+
110+
if item_count == 0 {
111+
return Ok(HashMap::new());
112+
}
113+
114+
// Initialize traversal state for all items
115+
let mut item_states: Vec<ItemTraversalState> = tree_items
116+
.into_iter()
117+
.map(|item| ItemTraversalState {
118+
current_hash: item.id,
119+
item,
120+
current_commit_id: start_commit_arc.id,
121+
determined: false,
122+
result_commit: None,
123+
})
124+
.collect();
125+
126+
let mut pending_count = item_count;
127+
128+
// Cache for commits we've loaded
129+
let mut commit_cache: HashMap<SHA1, Arc<Commit>> = HashMap::new();
130+
commit_cache.insert(start_commit_arc.id, start_commit_arc.clone());
131+
132+
let mut iteration = 0;
133+
134+
while pending_count > 0 {
135+
iteration += 1;
136+
if iteration > MAX_ITERATIONS {
137+
tracing::warn!(
138+
"[HISTORY] Exceeded max iterations ({}) for path {:?}, {} items still pending",
139+
MAX_ITERATIONS,
140+
path,
141+
pending_count
142+
);
143+
break;
144+
}
145+
146+
// Group undetermined items by their current commit
147+
let mut commit_groups: HashMap<SHA1, Vec<usize>> = HashMap::new();
148+
for (idx, state) in item_states.iter().enumerate() {
149+
if !state.determined {
150+
commit_groups
151+
.entry(state.current_commit_id)
152+
.or_default()
153+
.push(idx);
154+
}
155+
}
156+
157+
if commit_groups.is_empty() {
158+
break;
159+
}
160+
161+
let mut any_progress = false;
162+
163+
// Process each commit group
164+
for (commit_id, item_indices) in commit_groups {
165+
// Get the commit (from cache or load)
166+
let commit = if let Some(c) = commit_cache.get(&commit_id) {
167+
c.clone()
168+
} else {
169+
let c = handler
170+
.object_cache()
171+
.get_commit(commit_id, |id| async move {
172+
handler.get_commit_by_hash(&id.to_string()).await
173+
})
174+
.await?;
175+
commit_cache.insert(commit_id, c.clone());
176+
c
177+
};
178+
179+
// No parents: all items in this group were introduced in this commit
180+
if commit.parent_commit_ids.is_empty() {
181+
for &idx in &item_indices {
182+
if !item_states[idx].determined {
183+
item_states[idx].determined = true;
184+
item_states[idx].result_commit = Some((*commit).clone());
185+
pending_count -= 1;
186+
any_progress = true;
187+
}
188+
}
189+
continue;
190+
}
191+
192+
// Load all parent trees once (shared by all items in this group)
193+
let mut parent_data: Vec<ParentInfo> = Vec::new();
194+
195+
for &parent_id in &commit.parent_commit_ids {
196+
let parent_commit = if let Some(c) = commit_cache.get(&parent_id) {
197+
c.clone()
198+
} else {
199+
let c = handler
200+
.object_cache()
201+
.get_commit(parent_id, |id| async move {
202+
handler.get_commit_by_hash(&id.to_string()).await
203+
})
204+
.await?;
205+
commit_cache.insert(parent_id, c.clone());
206+
c
207+
};
208+
209+
let parent_tree = handler
210+
.object_cache()
211+
.get_tree(parent_commit.tree_id, |id| async move {
212+
handler.get_tree_by_hash(&id.to_string()).await
213+
})
214+
.await?;
215+
216+
let parent_target_tree_opt = navigate_to_tree(handler, parent_tree, &path).await?;
217+
218+
// Build hash map for this parent's items
219+
let parent_item_hashes: HashMap<String, (SHA1, TreeItemMode)> =
220+
if let Some(ref t) = parent_target_tree_opt {
221+
t.tree_items
222+
.iter()
223+
.map(|item| (item.name.clone(), (item.id, item.mode)))
224+
.collect()
225+
} else {
226+
HashMap::new()
227+
};
228+
229+
parent_data.push(ParentInfo {
230+
commit: parent_commit,
231+
item_hashes: parent_item_hashes,
232+
});
233+
}
234+
235+
// Process each item in this commit group
236+
for &idx in &item_indices {
237+
let state = &mut item_states[idx];
238+
if state.determined {
239+
continue;
240+
}
241+
242+
let item_name = &state.item.name;
243+
let item_mode = state.item.mode;
244+
let current_hash = state.current_hash;
245+
246+
// Find a parent that has the same hash (TREESAME)
247+
let mut found_matching_parent = false;
248+
249+
for parent in &parent_data {
250+
if let Some(&(parent_hash, parent_mode)) = parent.item_hashes.get(item_name)
251+
&& parent_hash == current_hash
252+
&& parent_mode == item_mode
253+
{
254+
// TREESAME: continue traversing to this parent
255+
state.current_commit_id = parent.commit.id;
256+
state.current_hash = parent_hash;
257+
found_matching_parent = true;
258+
any_progress = true;
259+
260+
break;
261+
}
262+
}
263+
264+
if !found_matching_parent {
265+
// No parent has the same hash: this commit modified the item
266+
state.determined = true;
267+
state.result_commit = Some((*commit).clone());
268+
pending_count -= 1;
269+
any_progress = true;
270+
}
271+
}
272+
}
273+
274+
if !any_progress {
275+
break;
276+
}
277+
}
278+
279+
// Build the result map
280+
let mut result = HashMap::with_capacity(item_count);
281+
for state in item_states {
282+
// Use result_commit, or fallback to start_commit if undetermined
283+
let commit = state
284+
.result_commit
285+
.or_else(|| Some((*start_commit_arc).clone()));
286+
result.insert(state.item, commit);
94287
}
95288

96289
Ok(result)
@@ -103,7 +296,6 @@ pub async fn item_to_commit_map<T: ApiHandler + ?Sized>(
103296
/// - `handler`: The API handler for Git operations.
104297
/// - `root_tree`: The root tree to start navigation from.
105298
/// - `path`: The path to navigate to.
106-
/// - `cache`: A shared cache for tree lookups.
107299
///
108300
/// # Returns
109301
/// - `Ok(Some(Arc<Tree>))` if the path exists and the target tree is found.
@@ -313,8 +505,6 @@ pub async fn traverse_commit_history_for_last_modification<T: ApiHandler + ?Size
313505
visited.insert(start_commit.id);
314506
commit_queue.push_back((start_commit.clone(), current_hash));
315507

316-
// Safety limit to prevent unbounded memory usage in complex histories
317-
const MAX_ITERATIONS: usize = 10_000;
318508
let mut iterations = 0;
319509

320510
while let Some((commit, commit_hash)) = commit_queue.pop_front() {

‎ceres/src/api_service/import_api_service.rs‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,23 +46,29 @@ impl ApiHandler for ImportApiService {
4646
}
4747

4848
fn strip_relative(&self, path: &Path) -> Result<PathBuf, MegaError> {
49-
// If the incoming path is already relative (does not start with repo base and is not absolute),
50-
// accept it as-is. This allows callers to pass either full repo path or already-stripped
51-
// relative paths without causing errors.
49+
// If the incoming path is already relative, accept it as-is
5250
if !path.is_absolute() {
5351
tracing::debug!("strip_relative -> path is already relative: {:?}", path);
5452
return Ok(path.to_path_buf());
5553
}
5654

57-
// Otherwise, expect an absolute path under the repo base and strip the prefix.
58-
match path.strip_prefix(self.repo.repo_path.clone()) {
55+
// Normalize both paths by removing leading slashes for consistent comparison
56+
let path_str = path.to_string_lossy();
57+
let path_trimmed = path_str.trim_start_matches('/');
58+
let repo_trimmed = self.repo.repo_path.trim_start_matches('/');
59+
60+
let path_normalized = Path::new(path_trimmed);
61+
let repo_normalized = Path::new(repo_trimmed);
62+
63+
match path_normalized.strip_prefix(repo_normalized) {
5964
Ok(relative_path) => {
6065
tracing::debug!("strip_relative -> relative={:?}", relative_path);
6166
Ok(relative_path.to_path_buf())
6267
}
6368
Err(_) => Err(MegaError::Other(format!(
64-
"The full path {:?} does not start with the base path {:?}.",
65-
path, self.repo.repo_path,
69+
"Path '{}' is not under repo '{}'",
70+
path.display(),
71+
self.repo.repo_path
6672
))),
6773
}
6874
}

0 commit comments

Comments
 (0)