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