gnostr_asyncgit/sync/remotes/
push.rs

1use crossbeam_channel::Sender;
2use git2::{PackBuilderStage, PushOptions};
3use scopetime::scope_time;
4
5use crate::{
6	error::{Error, Result},
7	progress::ProgressPercent,
8	sync::{
9		CommitId, RepoPath,
10		branch::branch_set_upstream_after_push,
11		cred::BasicAuthCredential,
12		remotes::{Callbacks, proxy_auto},
13		repository::repo,
14	},
15};
16
17///
18pub trait AsyncProgress: Clone + Send + Sync {
19	///
20	fn is_done(&self) -> bool;
21	///
22	fn progress(&self) -> ProgressPercent;
23}
24
25///
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum ProgressNotification {
28	///
29	UpdateTips {
30		///
31		name: String,
32		///
33		a: CommitId,
34		///
35		b: CommitId,
36	},
37	///
38	Transfer {
39		///
40		objects: usize,
41		///
42		total_objects: usize,
43	},
44	///
45	PushTransfer {
46		///
47		current: usize,
48		///
49		total: usize,
50		///
51		bytes: usize,
52	},
53	///
54	Packing {
55		///
56		stage: PackBuilderStage,
57		///
58		total: usize,
59		///
60		current: usize,
61	},
62	///
63	Done,
64}
65
66impl AsyncProgress for ProgressNotification {
67	fn is_done(&self) -> bool {
68		*self == Self::Done
69	}
70	fn progress(&self) -> ProgressPercent {
71		match *self {
72			Self::Packing {
73				stage,
74				current,
75				total,
76			} => match stage {
77				PackBuilderStage::AddingObjects
78				| PackBuilderStage::Deltafication => {
79					ProgressPercent::new(current, total)
80				}
81			},
82			Self::PushTransfer { current, total, .. } => {
83				ProgressPercent::new(current, total)
84			}
85			Self::Transfer {
86				objects,
87				total_objects,
88				..
89			} => ProgressPercent::new(objects, total_objects),
90			_ => ProgressPercent::full(),
91		}
92	}
93}
94
95///
96#[derive(Copy, Clone, Debug)]
97pub enum PushType {
98	///
99	Branch,
100	///
101	Tag,
102}
103
104impl Default for PushType {
105	fn default() -> Self {
106		Self::Branch
107	}
108}
109
110#[cfg(test)]
111pub fn push_branch(
112	repo_path: &RepoPath,
113	remote: &str,
114	branch: &str,
115	force: bool,
116	delete: bool,
117	basic_credential: Option<BasicAuthCredential>,
118	progress_sender: Option<Sender<ProgressNotification>>,
119) -> Result<()> {
120	push_raw(
121		repo_path,
122		remote,
123		branch,
124		PushType::Branch,
125		force,
126		delete,
127		basic_credential,
128		progress_sender,
129	)
130}
131
132//TODO: clenaup
133#[allow(clippy::too_many_arguments)]
134pub fn push_raw(
135	repo_path: &RepoPath,
136	remote: &str,
137	branch: &str,
138	ref_type: PushType,
139	force: bool,
140	delete: bool,
141	basic_credential: Option<BasicAuthCredential>,
142	progress_sender: Option<Sender<ProgressNotification>>,
143) -> Result<()> {
144	scope_time!("push");
145
146	let repo = repo(repo_path)?;
147	let mut remote = repo.find_remote(remote)?;
148
149	let mut options = PushOptions::new();
150	options.proxy_options(proxy_auto());
151
152	let callbacks = Callbacks::new(progress_sender, basic_credential);
153	options.remote_callbacks(callbacks.callbacks());
154	options.packbuilder_parallelism(0);
155
156	let branch_modifier = match (force, delete) {
157		(true, true) => "+:",
158		(false, true) => ":",
159		(true, false) => "+",
160		(false, false) => "",
161	};
162	let ref_type = match ref_type {
163		PushType::Branch => "heads",
164		PushType::Tag => "tags",
165	};
166
167	let branch_name =
168		format!("{branch_modifier}refs/{ref_type}/{branch}");
169	remote.push(&[branch_name.as_str()], Some(&mut options))?;
170
171	if let Some((reference, msg)) =
172		callbacks.get_stats()?.push_rejected_msg
173	{
174		return Err(Error::Generic(format!(
175			"push to '{reference}' rejected: {msg}"
176		)));
177	}
178
179	if !delete {
180		branch_set_upstream_after_push(&repo, branch)?;
181	}
182
183	Ok(())
184}
185
186#[cfg(test)]
187mod tests {
188	use std::{fs::File, io::Write, path::Path};
189
190	use git2::Repository;
191
192	use super::*;
193	use crate::sync::{
194		self,
195		tests::{
196			get_commit_ids, repo_clone, repo_init, repo_init_bare,
197			write_commit_file,
198		},
199	};
200
201	#[test]
202	fn test_force_push() {
203		// This test mimics the scenario of 2 people having 2
204		// local branches and both modifying the same file then
205		// both pushing, sequentially
206		let (tmp_repo_dir, repo) = repo_init().unwrap();
207		let (tmp_other_repo_dir, other_repo) = repo_init().unwrap();
208		let (tmp_upstream_dir, _) = repo_init_bare().unwrap();
209
210		repo.remote(
211			"origin",
212			tmp_upstream_dir.path().to_str().unwrap(),
213		)
214		.unwrap();
215
216		other_repo
217			.remote(
218				"origin",
219				tmp_upstream_dir.path().to_str().unwrap(),
220			)
221			.unwrap();
222
223		let tmp_repo_file_path =
224			tmp_repo_dir.path().join("temp_file.txt");
225		let mut tmp_repo_file =
226			File::create(tmp_repo_file_path).unwrap();
227		writeln!(tmp_repo_file, "TempSomething").unwrap();
228
229		sync::commit(
230			&tmp_repo_dir.path().to_str().unwrap().into(),
231			"repo_1_commit",
232		)
233		.unwrap();
234
235		push_branch(
236			&tmp_repo_dir.path().to_str().unwrap().into(),
237			"origin",
238			"master",
239			false,
240			false,
241			None,
242			None,
243		)
244		.unwrap();
245
246		let tmp_other_repo_file_path =
247			tmp_other_repo_dir.path().join("temp_file.txt");
248		let mut tmp_other_repo_file =
249			File::create(tmp_other_repo_file_path).unwrap();
250		writeln!(tmp_other_repo_file, "TempElse").unwrap();
251
252		sync::commit(
253			&tmp_other_repo_dir.path().to_str().unwrap().into(),
254			"repo_2_commit",
255		)
256		.unwrap();
257
258		// Attempt a normal push,
259		// should fail as branches diverged
260		assert_eq!(
261			push_branch(
262				&tmp_other_repo_dir.path().to_str().unwrap().into(),
263				"origin",
264				"master",
265				false,
266				false,
267				None,
268				None,
269			)
270			.is_err(),
271			true
272		);
273
274		// Attempt force push,
275		// should work as it forces the push through
276		assert_eq!(
277			push_branch(
278				&tmp_other_repo_dir.path().to_str().unwrap().into(),
279				"origin",
280				"master",
281				true,
282				false,
283				None,
284				None,
285			)
286			.is_err(),
287			false
288		);
289	}
290
291	#[test]
292	fn test_force_push_rewrites_history() {
293		// This test mimics the scenario of 2 people having 2
294		// local branches and both modifying the same file then
295		// both pushing, sequentially
296
297		let (tmp_repo_dir, repo) = repo_init().unwrap();
298		let (tmp_other_repo_dir, other_repo) = repo_init().unwrap();
299		let (tmp_upstream_dir, upstream) = repo_init_bare().unwrap();
300
301		repo.remote(
302			"origin",
303			tmp_upstream_dir.path().to_str().unwrap(),
304		)
305		.unwrap();
306
307		other_repo
308			.remote(
309				"origin",
310				tmp_upstream_dir.path().to_str().unwrap(),
311			)
312			.unwrap();
313
314		let tmp_repo_file_path =
315			tmp_repo_dir.path().join("temp_file.txt");
316		let mut tmp_repo_file =
317			File::create(tmp_repo_file_path).unwrap();
318		writeln!(tmp_repo_file, "TempSomething").unwrap();
319
320		sync::stage_add_file(
321			&tmp_repo_dir.path().to_str().unwrap().into(),
322			Path::new("temp_file.txt"),
323		)
324		.unwrap();
325
326		let repo_1_commit = sync::commit(
327			&tmp_repo_dir.path().to_str().unwrap().into(),
328			"repo_1_commit",
329		)
330		.unwrap();
331
332		//NOTE: make sure the commit actually contains that file
333		assert_eq!(
334			sync::get_commit_files(
335				&tmp_repo_dir.path().to_str().unwrap().into(),
336				repo_1_commit,
337				None
338			)
339			.unwrap()[0]
340				.path,
341			String::from("temp_file.txt")
342		);
343
344		let commits = get_commit_ids(&repo, 1);
345		assert!(commits.contains(&repo_1_commit));
346
347		push_branch(
348			&tmp_repo_dir.path().to_str().unwrap().into(),
349			"origin",
350			"master",
351			false,
352			false,
353			None,
354			None,
355		)
356		.unwrap();
357
358		let tmp_other_repo_file_path =
359			tmp_other_repo_dir.path().join("temp_file.txt");
360		let mut tmp_other_repo_file =
361			File::create(tmp_other_repo_file_path).unwrap();
362		writeln!(tmp_other_repo_file, "TempElse").unwrap();
363
364		sync::stage_add_file(
365			&tmp_other_repo_dir.path().to_str().unwrap().into(),
366			Path::new("temp_file.txt"),
367		)
368		.unwrap();
369
370		let repo_2_commit = sync::commit(
371			&tmp_other_repo_dir.path().to_str().unwrap().into(),
372			"repo_2_commit",
373		)
374		.unwrap();
375
376		let repo_2_parent = other_repo
377			.find_commit(repo_2_commit.into())
378			.unwrap()
379			.parents()
380			.next()
381			.unwrap()
382			.id();
383
384		let commits = get_commit_ids(&other_repo, 1);
385		assert!(commits.contains(&repo_2_commit));
386
387		// Attempt a normal push,
388		// should fail as branches diverged
389		assert_eq!(
390			push_branch(
391				&tmp_other_repo_dir.path().to_str().unwrap().into(),
392				"origin",
393				"master",
394				false,
395				false,
396				None,
397				None,
398			)
399			.is_err(),
400			true
401		);
402
403		// Check that the other commit is not in upstream,
404		// a normal push would not rewrite history
405		let commits = get_commit_ids(&upstream, 1);
406		assert!(!commits.contains(&repo_2_commit));
407
408		// Attempt force push,
409		// should work as it forces the push through
410
411		push_branch(
412			&tmp_other_repo_dir.path().to_str().unwrap().into(),
413			"origin",
414			"master",
415			true,
416			false,
417			None,
418			None,
419		)
420		.unwrap();
421
422		let commits = get_commit_ids(&upstream, 1);
423		assert!(commits.contains(&repo_2_commit));
424
425		let new_upstream_parent =
426			Repository::init_bare(tmp_upstream_dir.path())
427				.unwrap()
428				.find_commit(repo_2_commit.into())
429				.unwrap()
430				.parents()
431				.next()
432				.unwrap()
433				.id();
434		assert_eq!(new_upstream_parent, repo_2_parent,);
435	}
436
437	#[test]
438	fn test_delete_remote_branch() {
439		// This test mimics the scenario of a user creating a branch,
440		// push it, and then remove it on the remote
441
442		let (upstream_dir, upstream_repo) = repo_init_bare().unwrap();
443
444		let (tmp_repo_dir, repo) =
445			repo_clone(upstream_dir.path().to_str().unwrap())
446				.unwrap();
447
448		// You need a commit before being able to branch !
449		let commit_1 = write_commit_file(
450			&repo,
451			"temp_file.txt",
452			"SomeContent",
453			"Initial commit",
454		);
455
456		let commits = get_commit_ids(&repo, 1);
457		assert!(commits.contains(&commit_1));
458
459		push_branch(
460			&tmp_repo_dir.path().to_str().unwrap().into(),
461			"origin",
462			"master",
463			false,
464			false,
465			None,
466			None,
467		)
468		.unwrap();
469
470		// Create the local branch
471		sync::create_branch(
472			&tmp_repo_dir.path().to_str().unwrap().into(),
473			"test_branch",
474		)
475		.unwrap();
476
477		// Push the local branch
478		push_branch(
479			&tmp_repo_dir.path().to_str().unwrap().into(),
480			"origin",
481			"test_branch",
482			false,
483			false,
484			None,
485			None,
486		)
487		.unwrap();
488
489		// Test if the branch exits on the remote
490		assert_eq!(
491			upstream_repo
492				.branches(None)
493				.unwrap()
494				.map(std::result::Result::unwrap)
495				.map(|(i, _)| i.name().unwrap().unwrap().to_string())
496				.any(|i| &i == "test_branch"),
497			true
498		);
499
500		// Delete the remote branch
501		assert_eq!(
502			push_branch(
503				&tmp_repo_dir.path().to_str().unwrap().into(),
504				"origin",
505				"test_branch",
506				false,
507				true,
508				None,
509				None,
510			)
511			.is_ok(),
512			true
513		);
514
515		// Test that the branch has be remove from the remote
516		assert_eq!(
517			upstream_repo
518				.branches(None)
519				.unwrap()
520				.map(std::result::Result::unwrap)
521				.map(|(i, _)| i.name().unwrap().unwrap().to_string())
522				.any(|i| &i == "test_branch"),
523			false
524		);
525	}
526}