A tool to help managing forked repos with their own history
1use anyhow::{bail, Context, Result};
2use git2::{DiffOptions, Repository, ResetType, Status};
3use std::path::Path;
4
5pub const SOURCE_DIR: &str = "source";
6pub const FORKME_BRANCH: &str = "forkme";
7
8pub fn clone_repo(url: &str, branch: &str, depth: Option<usize>) -> Result<Repository> {
9 let depth_msg = match depth {
10 Some(d) => format!(" with depth {}", d),
11 None => String::new(),
12 };
13 println!("Cloning {} (branch: {}){}...", url, branch, depth_msg);
14
15 let mut builder = git2::build::RepoBuilder::new();
16 builder.branch(branch);
17
18 // Set fetch depth if provided
19 if let Some(d) = depth {
20 let mut fetch_options = git2::FetchOptions::new();
21 fetch_options.depth(d as i32);
22 builder.fetch_options(fetch_options);
23 }
24
25 let repo = builder
26 .clone(url, Path::new(SOURCE_DIR))
27 .with_context(|| format!("Failed to clone {} into {}", url, SOURCE_DIR))?;
28
29 println!("Clone complete.");
30 Ok(repo)
31}
32
33pub fn open_repo() -> Result<Repository> {
34 Repository::open(SOURCE_DIR)
35 .with_context(|| format!("Failed to open repository at {}", SOURCE_DIR))
36}
37
38pub fn create_forkme_branch(repo: &Repository, upstream_branch: &str) -> Result<()> {
39 let upstream_ref = format!("origin/{}", upstream_branch);
40 let reference = repo
41 .find_reference(&format!("refs/remotes/{}", upstream_ref))
42 .with_context(|| format!("Failed to find remote branch {}", upstream_ref))?;
43
44 let commit = reference.peel_to_commit()?;
45 create_forkme_branch_at(repo, commit.id())
46}
47
48pub fn create_forkme_branch_at(repo: &Repository, oid: git2::Oid) -> Result<()> {
49 let commit = repo.find_commit(oid)?;
50
51 // Create the forkme branch
52 repo.branch(FORKME_BRANCH, &commit, false)
53 .with_context(|| format!("Failed to create branch {}", FORKME_BRANCH))?;
54
55 // Checkout the forkme branch
56 let obj = repo.revparse_single(&format!("refs/heads/{}", FORKME_BRANCH))?;
57 repo.checkout_tree(&obj, None)?;
58 repo.set_head(&format!("refs/heads/{}", FORKME_BRANCH))?;
59
60 println!("Created and checked out branch '{}'", FORKME_BRANCH);
61 Ok(())
62}
63
64pub fn get_upstream_commit(repo: &Repository, branch: &str) -> Result<git2::Oid> {
65 let upstream_ref = format!("refs/remotes/origin/{}", branch);
66 let reference = repo
67 .find_reference(&upstream_ref)
68 .with_context(|| format!("Failed to find upstream branch {}", branch))?;
69
70 Ok(reference.peel_to_commit()?.id())
71}
72
73pub fn get_upstream_commit_sha(repo: &Repository, branch: &str) -> Result<String> {
74 let oid = get_upstream_commit(repo, branch)?;
75 Ok(oid.to_string())
76}
77
78pub fn resolve_commit(repo: &Repository, sha: &str) -> Result<git2::Oid> {
79 let oid = git2::Oid::from_str(sha).with_context(|| format!("Invalid commit SHA: {}", sha))?;
80 // Verify the commit exists
81 repo.find_commit(oid).with_context(|| {
82 format!(
83 "Commit {} not found. You may need a deeper clone (remove --depth option).",
84 sha
85 )
86 })?;
87 Ok(oid)
88}
89
90pub fn reset_to_upstream(repo: &Repository, branch: &str) -> Result<()> {
91 let upstream_oid = get_upstream_commit(repo, branch)?;
92 reset_to_commit(repo, upstream_oid)
93}
94
95pub fn reset_to_commit(repo: &Repository, oid: git2::Oid) -> Result<()> {
96 let commit = repo.find_commit(oid)?;
97 let obj = commit.as_object();
98
99 repo.reset(obj, ResetType::Hard, None)
100 .with_context(|| "Failed to reset to upstream")?;
101
102 println!("Reset to {}", &oid.to_string()[..12]);
103 Ok(())
104}
105
106pub fn is_working_tree_clean(repo: &Repository) -> Result<bool> {
107 let statuses = repo.statuses(None)?;
108
109 if !statuses.is_empty() {
110 for status in &statuses {
111 if status.status() != Status::IGNORED {
112 return Ok(false);
113 }
114 }
115 }
116 Ok(true)
117}
118
119pub fn has_uncommitted_changes(repo: &Repository, file_path: &str) -> Result<bool> {
120 let statuses = repo.statuses(None)?;
121
122 for entry in statuses.iter() {
123 if let Some(path) = entry.path() {
124 if path == file_path {
125 // Check if file has any uncommitted changes (modified, added, deleted, etc.)
126 let status = entry.status();
127 return Ok(!status.is_empty());
128 }
129 }
130 }
131
132 Ok(false)
133}
134
135pub fn ensure_on_forkme_branch(repo: &Repository) -> Result<()> {
136 let head = repo.head()?;
137 let branch_name = head.shorthand().unwrap_or("");
138
139 if branch_name != FORKME_BRANCH {
140 bail!(
141 "Not on '{}' branch. Currently on '{}'. Please checkout '{}'.",
142 FORKME_BRANCH,
143 branch_name,
144 FORKME_BRANCH
145 );
146 }
147 Ok(())
148}
149
150pub enum FileContent {
151 Text(String),
152 Binary(Vec<u8>),
153}
154
155impl FileContent {
156 #[allow(dead_code)]
157 pub fn is_binary(&self) -> bool {
158 matches!(self, FileContent::Binary(_))
159 }
160
161 pub fn as_text(&self) -> Option<&str> {
162 match self {
163 FileContent::Text(s) => Some(s),
164 FileContent::Binary(_) => None,
165 }
166 }
167
168 #[allow(dead_code)]
169 pub fn as_bytes(&self) -> &[u8] {
170 match self {
171 FileContent::Text(s) => s.as_bytes(),
172 FileContent::Binary(b) => b,
173 }
174 }
175}
176
177pub struct FileDiff {
178 pub path: String,
179 pub old_content: Option<FileContent>,
180 pub new_content: Option<FileContent>,
181}
182
183pub fn get_changes_from_upstream(
184 repo: &Repository,
185 upstream_branch: &str,
186) -> Result<Vec<FileDiff>> {
187 let upstream_oid = get_upstream_commit(repo, upstream_branch)?;
188 let upstream_commit = repo.find_commit(upstream_oid)?;
189 let upstream_tree = upstream_commit.tree()?;
190
191 let head = repo.head()?;
192 let head_commit = head.peel_to_commit()?;
193 let head_tree = head_commit.tree()?;
194
195 let mut diff_opts = DiffOptions::new();
196 let diff =
197 repo.diff_tree_to_tree(Some(&upstream_tree), Some(&head_tree), Some(&mut diff_opts))?;
198
199 let mut changes = Vec::new();
200
201 diff.foreach(
202 &mut |delta, _| {
203 let path = delta
204 .new_file()
205 .path()
206 .or_else(|| delta.old_file().path())
207 .map(|p| p.to_string_lossy().to_string())
208 .unwrap_or_default();
209
210 let old_content = if delta.old_file().id().is_zero() {
211 None
212 } else {
213 repo.find_blob(delta.old_file().id())
214 .ok()
215 .map(|blob| blob_to_content(&blob))
216 };
217
218 let new_content = if delta.new_file().id().is_zero() {
219 None
220 } else {
221 repo.find_blob(delta.new_file().id())
222 .ok()
223 .map(|blob| blob_to_content(&blob))
224 };
225
226 changes.push(FileDiff {
227 path,
228 old_content,
229 new_content,
230 });
231
232 true
233 },
234 None,
235 None,
236 None,
237 )?;
238
239 Ok(changes)
240}
241
242fn blob_to_content(blob: &git2::Blob) -> FileContent {
243 let bytes = blob.content();
244 // Check if content is valid UTF-8 and doesn't contain null bytes
245 if let Ok(text) = std::str::from_utf8(bytes) {
246 if !bytes.contains(&0) {
247 return FileContent::Text(text.to_string());
248 }
249 }
250 FileContent::Binary(bytes.to_vec())
251}
252
253pub fn commit_changes(repo: &Repository, message: &str) -> Result<()> {
254 let mut index = repo.index()?;
255 index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
256 index.write()?;
257
258 let tree_id = index.write_tree()?;
259 let tree = repo.find_tree(tree_id)?;
260
261 let head = repo.head()?;
262 let parent = head.peel_to_commit()?;
263
264 let sig = repo.signature()?;
265 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?;
266
267 Ok(())
268}