Commit
+179 -5 +/-2 browse
1 | diff --git a/crates/git/src/lite.rs b/crates/git/src/lite.rs |
2 | index a5fa0d2..8c8646e 100644 |
3 | --- a/crates/git/src/lite.rs |
4 | +++ b/crates/git/src/lite.rs |
5 | @@ -192,7 +192,10 @@ impl From<&GitBlob<'_>> for Blob { |
6 | #[derive(Clone, Serialize)] |
7 | pub struct Branch { |
8 | pub name: String, |
9 | - pub commit: Commit, |
10 | + // the current HEAD of the branch |
11 | + pub head: Commit, |
12 | + // the first commit unique to the branch |
13 | + pub origin: Option<Commit>, |
14 | } |
15 | |
16 | impl From<(GitBranch<'_>, GitCommit<'_>)> for Branch { |
17 | @@ -200,7 +203,8 @@ impl From<(GitBranch<'_>, GitCommit<'_>)> for Branch { |
18 | let name = entry.0.name().unwrap_or(Some("?")).unwrap_or("?"); |
19 | Branch { |
20 | name: name.to_string(), |
21 | - commit: Commit::from(entry.1), |
22 | + head: Commit::from(entry.1), |
23 | + origin: None, |
24 | } |
25 | } |
26 | } |
27 | diff --git a/crates/git/src/wrapper.rs b/crates/git/src/wrapper.rs |
28 | index 79dcdd5..9112ec7 100644 |
29 | --- a/crates/git/src/wrapper.rs |
30 | +++ b/crates/git/src/wrapper.rs |
31 | @@ -1,5 +1,5 @@ |
32 | // use async_std::process as async_process; |
33 | - use std::collections::HashMap; |
34 | + use std::collections::{BTreeMap, HashMap}; |
35 | use std::fs; |
36 | use std::path::{Path, PathBuf}; |
37 | use std::process::{Command, Stdio}; |
38 | @@ -90,6 +90,33 @@ impl Wrapper { |
39 | Ok(references.count() == 0) |
40 | } |
41 | |
42 | + /// return commits starting from first_commit until last_commit is reached |
43 | + /// or limit is exceeded. |
44 | + pub fn commits_range_between( |
45 | + &self, |
46 | + first_commit: &str, |
47 | + last_commit: &str, |
48 | + limit: Option<usize>, |
49 | + ) -> Result<Vec<lite::Commit>, Error> { |
50 | + let mut commits: Vec<lite::Commit> = Vec::new(); |
51 | + let first_commit_id = Oid::from_str(first_commit)?; |
52 | + let last_commit_id = Oid::from_str(last_commit)?; |
53 | + let mut revwalk = self.repository.revwalk()?; |
54 | + revwalk.push(first_commit_id)?; |
55 | + for (i, commit) in revwalk.enumerate() { |
56 | + let commit_id = commit?; |
57 | + if limit.is_some_and(|limit| limit >= i) { |
58 | + break; |
59 | + } |
60 | + let resolved = self.repository.find_commit(commit_id)?; |
61 | + commits.push(resolved.into()); |
62 | + if commit_id == last_commit_id { |
63 | + break; |
64 | + } |
65 | + } |
66 | + Ok(commits) |
67 | + } |
68 | + |
69 | // read all commits up until last_commit or everything |
70 | pub fn commits( |
71 | &self, |
72 | @@ -179,8 +206,110 @@ impl Wrapper { |
73 | Ok(tags) |
74 | } |
75 | |
76 | - pub fn branches_range(&self, timeframe: Option<(i64, i64)>) -> Result<(), Error> { |
77 | - Ok(()) |
78 | + fn resolve_branches( |
79 | + &self, |
80 | + commit_map: &mut BTreeMap<Oid, bool>, |
81 | + timeframe: Option<&(i64, i64)>, |
82 | + ) -> Result<Vec<lite::Branch>, Error> { |
83 | + let mut branches: Vec<lite::Branch> = Vec::new(); |
84 | + // walk commits of each branch except for the main branch stopping the |
85 | + // iteration when a commit has already been seen. |
86 | + 'branch_loop: for current_branch in self.repository.branches(Some(BranchType::Local))? { |
87 | + let branch = current_branch?; |
88 | + let branch_name = branch.0.name()?.unwrap().to_string(); |
89 | + if branch.0.is_head() { |
90 | + // skip the HEAD branch (typically main) |
91 | + continue 'branch_loop; |
92 | + } |
93 | + let head_of_current_branch = branch.0.get().target().unwrap(); |
94 | + let mut revwalk = self.repository.revwalk()?; |
95 | + revwalk.push(head_of_current_branch)?; |
96 | + revwalk.set_sorting(Sort::TOPOLOGICAL)?; |
97 | + // revwalk.push_ref(&branch.0.into_reference().target)?; |
98 | + let mut previous_commit: Option<Oid> = None; |
99 | + for commit in revwalk { |
100 | + let commit_id = commit?; |
101 | + // if the commit has already been seen we are at the boundry of a branch |
102 | + if commit_map.insert(commit_id, true).is_some() { |
103 | + if let Some(previous_commit) = previous_commit { |
104 | + // the "origin" commit of this branch |
105 | + let previous_commit = self.repository.find_commit(previous_commit)?; |
106 | + if let Some(timeframe) = timeframe { |
107 | + let epoch = previous_commit.time().seconds(); |
108 | + if epoch >= timeframe.0 && epoch <= timeframe.1 { |
109 | + let head_of_current_branch = |
110 | + self.repository.find_commit(head_of_current_branch)?; |
111 | + branches.push(lite::Branch { |
112 | + name: branch_name.clone(), |
113 | + head: lite::Commit::from(head_of_current_branch), |
114 | + origin: Some(lite::Commit::from(previous_commit)), |
115 | + }); |
116 | + } |
117 | + } else { |
118 | + // if there is no filter all branches are considered |
119 | + let head_of_current_branch = |
120 | + self.repository.find_commit(head_of_current_branch)?; |
121 | + branches.push(lite::Branch { |
122 | + name: branch_name.clone(), |
123 | + head: lite::Commit::from(head_of_current_branch), |
124 | + origin: Some(lite::Commit::from(previous_commit)), |
125 | + }); |
126 | + } |
127 | + } |
128 | + continue 'branch_loop; |
129 | + } |
130 | + previous_commit = Some(commit_id); |
131 | + } |
132 | + todo!("branch is detached or has zero commits") |
133 | + } |
134 | + Ok(branches) |
135 | + } |
136 | + |
137 | + /// return a range of branches considering the first commit to be their |
138 | + /// "origin" which is used to determine the age of the branch. All branches |
139 | + /// with origin timestamps within the timeframe will be returned. |
140 | + /// |
141 | + /// For example: |
142 | + /// |
143 | + /// MAIN B1 B3 |
144 | + /// HEAD |
145 | + /// |-1A HEAD HEAD |
146 | + /// |-1B |-2B* |-3B* |
147 | + /// |-1C |-2C ----|-3C* |
148 | + /// |-1D ----|-2D* |
149 | + /// |-1E |
150 | + /// |
151 | + /// B1 |
152 | + /// HEAD - 2B |
153 | + /// ORIGIN - 2D |
154 | + /// |
155 | + /// B3 |
156 | + /// HEAD - 3B |
157 | + /// ORIGIN - 3C |
158 | + /// |
159 | + pub fn branches_range( |
160 | + &self, |
161 | + timeframe: Option<(i64, i64)>, |
162 | + ) -> Result<Vec<lite::Branch>, Error> { |
163 | + let mut commit_map: BTreeMap<Oid, bool> = BTreeMap::new(); |
164 | + // traverse the commits in the main branch that match the timeframe |
165 | + let mut revwalk = self.repository.revwalk()?; |
166 | + revwalk.push_head()?; |
167 | + for commit in revwalk { |
168 | + let commit_id = commit?; |
169 | + let commit = self.repository.find_commit(commit_id)?; |
170 | + if let Some(timeframe) = timeframe { |
171 | + let epoch_seconds = commit.time().seconds(); |
172 | + if epoch_seconds >= timeframe.0 && epoch_seconds <= timeframe.1 { |
173 | + commit_map.insert(commit_id, true); |
174 | + } |
175 | + } else { |
176 | + commit_map.insert(commit_id, true); |
177 | + } |
178 | + } |
179 | + let branches = self.resolve_branches(&mut commit_map, timeframe.as_ref())?; |
180 | + // branches.extend(self.resolve_branches(&mut commit_map)?); |
181 | + Ok(branches) |
182 | } |
183 | |
184 | // create a worktree and expose it to the function any always clean up |
185 | @@ -949,4 +1078,45 @@ mod tests { |
186 | assert!(commits[0].message.trim_end() == "commit 2"); |
187 | assert!(commits[1].message.trim_end() == "commit 1"); |
188 | } |
189 | + |
190 | + #[test] |
191 | + fn test_git_branch_range() { |
192 | + let timestamp_1 = "Fri Jul 14 02:40:00 AM UTC 2017"; // @1500000000 |
193 | + let timestamp_2 = "Fri Jul 14 02:40:01 AM UTC 2017"; // @1500000001 |
194 | + let timestamp_3 = "Sun Sep 13 12:26:40 PM UTC 2020"; // @1600000000 |
195 | + let mut test_repo = testing::Builder::default().with_commands(vec![ |
196 | + format!("echo 'content' > file_1.txt && git add file_1.txt && GIT_COMMITTER_DATE='{}' GIT_AUTHOR_DATE='{}' git commit -m 'commit 1'", timestamp_1, timestamp_1).as_str(), |
197 | + // branch within the requested timeframe |
198 | + "git checkout -b hello-world", |
199 | + format!("echo 'content' > file_2.txt && git add file_2.txt && GIT_COMMITTER_DATE='{}' GIT_AUTHOR_DATE='{}' git commit -m 'commit 2'", timestamp_2, timestamp_2).as_str(), |
200 | + // third commit exceeds the timeframe |
201 | + format!("echo 'content' > file_3.txt && git add file_3.txt && GIT_COMMITTER_DATE='{}' GIT_AUTHOR_DATE='{}' git commit -m 'commit 3'", timestamp_3, timestamp_3).as_str(), |
202 | + // more commits outside of timeframe and another branch |
203 | + "git checkout main && git checkout -b another-branch", |
204 | + "echo 'content' > file_4.txt && git add file_4.txt && git commit -m 'commit 5'", |
205 | + "echo 'content' > file_5.txt && git add file_5.txt && git commit -m 'commit 6'", |
206 | + "git checkout main", |
207 | + "echo 'content' > file_6.txt && git add file_6.txt && git commit -m 'commit 7'", |
208 | + // one more branch with no commits on it |
209 | + "git checkout -b one-more-branch" |
210 | + ]); |
211 | + let repo_path = test_repo.build().expect("failed to init repo").1; |
212 | + let repository = Wrapper::new(&repo_path).expect("failed to load repository"); |
213 | + let branches = repository |
214 | + .branches_range(Some((1499999999, 1550000000))) |
215 | + .expect("failed to get range"); |
216 | + assert!(branches.len() == 1); |
217 | + let resolved_branch = branches.first().unwrap(); |
218 | + assert!(resolved_branch.name == "hello-world"); |
219 | + let origin_commit = resolved_branch.clone().origin.unwrap(); |
220 | + assert!(origin_commit.message.trim_end() == "commit 2"); |
221 | + // no time range should return two branches |
222 | + let branches = repository |
223 | + .branches_range(None) |
224 | + .expect("failed to get range"); |
225 | + assert!(branches.len() == 2); |
226 | + let branch_1 = branches.first().unwrap(); |
227 | + assert!(branch_1.name.trim_end() == "another-branch"); |
228 | + assert!(branch_1.origin.clone().unwrap().message.trim_end() == "commit 5"); |
229 | + } |
230 | } |