just playing with tangled
1// Copyright 2025 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::path::Path;
16use std::path::PathBuf;
17
18pub const GIT_USER: &str = "Someone";
19pub const GIT_EMAIL: &str = "someone@example.org";
20
21fn git_config() -> Vec<bstr::BString> {
22 vec![
23 format!("user.name = {GIT_USER}").into(),
24 format!("user.email = {GIT_EMAIL}").into(),
25 "init.defaultBranch = master".into(),
26 ]
27}
28
29fn open_options() -> gix::open::Options {
30 gix::open::Options::isolated()
31 .config_overrides(git_config())
32 .strict_config(true)
33}
34
35pub fn open(directory: impl Into<PathBuf>) -> gix::Repository {
36 gix::open_opts(directory, open_options()).unwrap()
37}
38
39pub fn init(directory: impl AsRef<Path>) -> gix::Repository {
40 gix::ThreadSafeRepository::init_opts(
41 directory,
42 gix::create::Kind::WithWorktree,
43 gix::create::Options::default(),
44 open_options(),
45 )
46 .unwrap()
47 .to_thread_local()
48}
49
50pub fn init_bare(directory: impl AsRef<Path>) -> gix::Repository {
51 gix::ThreadSafeRepository::init_opts(
52 directory,
53 gix::create::Kind::Bare,
54 gix::create::Options::default(),
55 open_options(),
56 )
57 .unwrap()
58 .to_thread_local()
59}
60
61pub fn clone(dest_path: &Path, repo_url: &str, remote_name: Option<&str>) -> gix::Repository {
62 let remote_name = remote_name.unwrap_or("origin");
63 // gitoxide doesn't write the remote HEAD as a symbolic link, which prevents
64 // `jj` from getting it.
65 //
66 // This, plus the fact that the code to clone a repo in gitoxide is non-trivial,
67 // makes it appealing to just spawn a git subprocess
68 let output = std::process::Command::new("git")
69 .args(["clone", repo_url, "--origin", remote_name])
70 .arg(dest_path)
71 .output()
72 .unwrap();
73 assert!(
74 output.status.success(),
75 "git cloning failed with {}:\n{}\n----- stderr -----\n{}",
76 output.status,
77 bstr::BString::from(output.stdout),
78 bstr::BString::from(output.stderr),
79 );
80
81 open(dest_path)
82}
83
84/// Writes out gitlink entry pointing to the `target_repo`.
85pub fn create_gitlink(src_repo: impl AsRef<Path>, target_repo: impl AsRef<Path>) {
86 let git_link_path = src_repo.as_ref().join(".git");
87 std::fs::write(
88 git_link_path,
89 format!("gitdir: {}\n", target_repo.as_ref().display()),
90 )
91 .unwrap();
92}
93
94pub fn remove_config_value(mut repo: gix::Repository, section: &str, key: &str) {
95 let mut config = repo.config_snapshot_mut();
96 let Ok(mut section) = config.section_mut(section, None) else {
97 return;
98 };
99 section.remove(key);
100
101 let mut file = std::fs::File::create(config.meta().path.as_ref().unwrap()).unwrap();
102 config
103 .write_to_filter(&mut file, |section| section.meta() == config.meta())
104 .unwrap();
105}
106
107pub struct CommitResult {
108 pub tree_id: gix::ObjectId,
109 pub commit_id: gix::ObjectId,
110}
111
112pub fn add_commit(
113 repo: &gix::Repository,
114 reference: &str,
115 filename: &str,
116 content: &[u8],
117 message: &str,
118 parents: &[gix::ObjectId],
119) -> CommitResult {
120 let blob_oid = repo.write_blob(content).unwrap();
121
122 let parent_tree_editor = parents.first().map(|commit_id| {
123 repo.find_commit(*commit_id)
124 .unwrap()
125 .tree()
126 .unwrap()
127 .edit()
128 .unwrap()
129 });
130 let empty_tree_editor_fn = || {
131 repo.edit_tree(gix::ObjectId::empty_tree(repo.object_hash()))
132 .unwrap()
133 };
134
135 let mut tree_editor = parent_tree_editor.unwrap_or_else(empty_tree_editor_fn);
136 tree_editor
137 .upsert(filename, gix::object::tree::EntryKind::Blob, blob_oid)
138 .unwrap();
139 let tree_id = tree_editor.write().unwrap().detach();
140 let commit_id = write_commit(repo, reference, tree_id, message, parents);
141 CommitResult { tree_id, commit_id }
142}
143
144pub fn write_commit(
145 repo: &gix::Repository,
146 reference: &str,
147 tree_id: gix::ObjectId,
148 message: &str,
149 parents: &[gix::ObjectId],
150) -> gix::ObjectId {
151 let signature = signature();
152 repo.commit_as(
153 &signature,
154 &signature,
155 reference,
156 message,
157 tree_id,
158 parents.iter().copied(),
159 )
160 .unwrap()
161 .detach()
162}
163
164pub fn set_head_to_id(repo: &gix::Repository, target: gix::ObjectId) {
165 repo.edit_reference(gix::refs::transaction::RefEdit {
166 change: gix::refs::transaction::Change::Update {
167 log: gix::refs::transaction::LogChange::default(),
168 expected: gix::refs::transaction::PreviousValue::Any,
169 new: gix::refs::Target::Object(target),
170 },
171 name: "HEAD".try_into().unwrap(),
172 deref: false,
173 })
174 .unwrap();
175}
176
177pub fn set_symbolic_reference(repo: &gix::Repository, reference: &str, target: &str) {
178 use gix::refs::transaction;
179 let change = transaction::Change::Update {
180 log: transaction::LogChange {
181 mode: transaction::RefLog::AndReference,
182 force_create_reflog: true,
183 message: "create symbolic reference".into(),
184 },
185 expected: transaction::PreviousValue::Any,
186 new: gix::refs::Target::Symbolic(target.try_into().unwrap()),
187 };
188
189 let ref_edit = transaction::RefEdit {
190 change,
191 name: reference.try_into().unwrap(),
192 deref: false,
193 };
194 repo.edit_reference(ref_edit).unwrap();
195}
196
197pub fn checkout_tree_index(repo: &gix::Repository, tree_id: gix::ObjectId) {
198 let objects = repo.objects.clone();
199 let mut index = repo.index_from_tree(&tree_id).unwrap();
200 gix::worktree::state::checkout(
201 &mut index,
202 repo.workdir().unwrap(),
203 objects,
204 &gix::progress::Discard,
205 &gix::progress::Discard,
206 &gix::interrupt::IS_INTERRUPTED,
207 gix::worktree::state::checkout::Options::default(),
208 )
209 .unwrap();
210}
211
212fn signature() -> gix::actor::Signature {
213 gix::actor::Signature {
214 name: bstr::BString::from(GIT_USER),
215 email: bstr::BString::from(GIT_EMAIL),
216 time: gix::date::Time::new(0, 0),
217 }
218}
219
220#[derive(Debug, PartialEq, Eq)]
221pub enum GitStatusInfo {
222 Index(IndexStatus),
223 Worktree(WorktreeStatus),
224}
225
226#[derive(Debug, PartialEq, Eq)]
227pub enum IndexStatus {
228 Addition,
229 Deletion,
230 Rename,
231 Modification,
232}
233
234#[derive(Debug, PartialEq, Eq)]
235pub enum WorktreeStatus {
236 Removed,
237 Added,
238 Modified,
239 TypeChange,
240 Renamed,
241 Copied,
242 IntentToAdd,
243 Conflict,
244 Ignored,
245}
246
247impl<'lhs, 'rhs> From<gix::diff::index::ChangeRef<'lhs, 'rhs>> for IndexStatus {
248 fn from(value: gix::diff::index::ChangeRef<'lhs, 'rhs>) -> Self {
249 match value {
250 gix::diff::index::ChangeRef::Addition { .. } => IndexStatus::Addition,
251 gix::diff::index::ChangeRef::Deletion { .. } => IndexStatus::Deletion,
252 gix::diff::index::ChangeRef::Rewrite { .. } => IndexStatus::Rename,
253 gix::diff::index::ChangeRef::Modification { .. } => IndexStatus::Modification,
254 }
255 }
256}
257
258impl From<Option<gix::status::index_worktree::iter::Summary>> for WorktreeStatus {
259 fn from(value: Option<gix::status::index_worktree::iter::Summary>) -> Self {
260 match value {
261 Some(gix::status::index_worktree::iter::Summary::Removed) => WorktreeStatus::Removed,
262 Some(gix::status::index_worktree::iter::Summary::Added) => WorktreeStatus::Added,
263 Some(gix::status::index_worktree::iter::Summary::Modified) => WorktreeStatus::Modified,
264 Some(gix::status::index_worktree::iter::Summary::TypeChange) => {
265 WorktreeStatus::TypeChange
266 }
267 Some(gix::status::index_worktree::iter::Summary::Renamed) => WorktreeStatus::Renamed,
268 Some(gix::status::index_worktree::iter::Summary::Copied) => WorktreeStatus::Copied,
269 Some(gix::status::index_worktree::iter::Summary::IntentToAdd) => {
270 WorktreeStatus::IntentToAdd
271 }
272 Some(gix::status::index_worktree::iter::Summary::Conflict) => WorktreeStatus::Conflict,
273 None => WorktreeStatus::Ignored,
274 }
275 }
276}
277
278impl From<gix::status::Item> for GitStatusInfo {
279 fn from(value: gix::status::Item) -> Self {
280 match value {
281 gix::status::Item::TreeIndex(change) => GitStatusInfo::Index(change.into()),
282 gix::status::Item::IndexWorktree(item) => {
283 GitStatusInfo::Worktree(item.summary().into())
284 }
285 }
286 }
287}
288
289#[derive(Debug, PartialEq, Eq)]
290pub struct GitStatus {
291 path: String,
292 status: GitStatusInfo,
293}
294
295impl From<gix::status::Item> for GitStatus {
296 fn from(value: gix::status::Item) -> Self {
297 let path = value.location().to_string();
298 let status = value.into();
299 GitStatus { path, status }
300 }
301}
302
303pub fn status(repo: &gix::Repository) -> Vec<GitStatus> {
304 let mut status: Vec<GitStatus> = repo
305 .status(gix::progress::Discard)
306 .unwrap()
307 .untracked_files(gix::status::UntrackedFiles::Files)
308 .dirwalk_options(|options| {
309 options.emit_ignored(Some(gix::dir::walk::EmissionMode::Matching))
310 })
311 .into_iter(None)
312 .unwrap()
313 .map(Result::unwrap)
314 .map(|x| x.into())
315 .collect();
316
317 status.sort_by(|a, b| a.path.cmp(&b.path));
318 status
319}
320
321pub struct IndexManager<'a> {
322 index: gix::index::File,
323 repo: &'a gix::Repository,
324}
325
326impl<'a> IndexManager<'a> {
327 pub fn new(repo: &'a gix::Repository) -> IndexManager<'a> {
328 // This would be equivalent to repo.open_index_or_empty() if such
329 // function existed.
330 let index = repo.index_or_empty().unwrap();
331 let index = gix::index::File::clone(&index); // unshare
332 IndexManager { index, repo }
333 }
334
335 pub fn add_file(&mut self, name: &str, data: &[u8]) {
336 std::fs::write(self.repo.workdir().unwrap().join(name), data).unwrap();
337 let blob_oid = self.repo.write_blob(data).unwrap().detach();
338
339 self.index.dangerously_push_entry(
340 gix::index::entry::Stat::default(),
341 blob_oid,
342 gix::index::entry::Flags::from_stage(gix::index::entry::Stage::Unconflicted),
343 gix::index::entry::Mode::FILE,
344 name.as_bytes().into(),
345 );
346 }
347
348 pub fn sync_index(&mut self) {
349 self.index.sort_entries();
350 self.index.verify_entries().unwrap();
351 self.index
352 .write(gix::index::write::Options::default())
353 .unwrap();
354 }
355}
356
357pub fn add_remote(repo_dir: impl AsRef<Path>, remote_name: &str, url: &str) {
358 let output = std::process::Command::new("git")
359 .current_dir(repo_dir)
360 .args(["remote", "add", remote_name, url])
361 .output()
362 .unwrap();
363 assert!(
364 output.status.success(),
365 "git remote add {remote_name} {url} failed with {}:\n{}\n----- stderr -----\n{}",
366 output.status,
367 bstr::BString::from(output.stdout),
368 bstr::BString::from(output.stderr),
369 );
370}
371
372pub fn rename_remote(repo_dir: impl AsRef<Path>, original: &str, new: &str) {
373 let output = std::process::Command::new("git")
374 .current_dir(repo_dir)
375 .args(["remote", "rename", original, new])
376 .output()
377 .unwrap();
378 assert!(
379 output.status.success(),
380 "git remote rename failed with {}:\n{}\n----- stderr -----\n{}",
381 output.status,
382 bstr::BString::from(output.stdout),
383 bstr::BString::from(output.stderr),
384 );
385}
386
387pub fn fetch(repo_dir: impl AsRef<Path>, remote: &str) {
388 let output = std::process::Command::new("git")
389 .current_dir(repo_dir)
390 .args(["fetch", remote])
391 .output()
392 .unwrap();
393 assert!(
394 output.status.success(),
395 "git fetch {remote} failed with {}:\n{}\n----- stderr -----\n{}",
396 output.status,
397 bstr::BString::from(output.stdout),
398 bstr::BString::from(output.stderr),
399 );
400}