Commit
Author: Kevin Schoon [me@kevinschoon.com]
Hash: 83d7a6c59ff25d56288d38d16815091119173760
Timestamp: Thu, 04 Apr 2024 20:33:28 +0000 (11 months ago)

+179 -5 +/-2 browse
add commits_range_between and branches range
add commits_range_between and branches range

Adds two new git related methods for resolving the commits between two
commits and returning branches within a certain range. This is being added
to support new UI and RSS features.
1diff --git a/crates/git/src/lite.rs b/crates/git/src/lite.rs
2index 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
28index 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 }