just playing with tangled
1// Copyright 2024 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
15//! Git utilities shared by various commands.
16
17use std::io::{Read, Write};
18use std::path::{Path, PathBuf};
19use std::process::Stdio;
20use std::time::Instant;
21use std::{error, iter};
22
23use itertools::Itertools;
24use jj_lib::git::{self, FailedRefExport, FailedRefExportReason, GitImportStats, RefName};
25use jj_lib::git_backend::GitBackend;
26use jj_lib::op_store::{RefTarget, RemoteRef};
27use jj_lib::repo::{ReadonlyRepo, Repo};
28use jj_lib::store::Store;
29use jj_lib::workspace::Workspace;
30use unicode_width::UnicodeWidthStr;
31
32use crate::command_error::{user_error, CommandError};
33use crate::formatter::Formatter;
34use crate::progress::Progress;
35use crate::ui::Ui;
36
37pub fn get_git_repo(store: &Store) -> Result<git2::Repository, CommandError> {
38 match store.backend_impl().downcast_ref::<GitBackend>() {
39 None => Err(user_error("The repo is not backed by a git repo")),
40 Some(git_backend) => Ok(git_backend.open_git_repo()?),
41 }
42}
43
44pub fn is_colocated_git_workspace(workspace: &Workspace, repo: &ReadonlyRepo) -> bool {
45 let Some(git_backend) = repo.store().backend_impl().downcast_ref::<GitBackend>() else {
46 return false;
47 };
48 let Some(git_workdir) = git_backend.git_workdir() else {
49 return false; // Bare repository
50 };
51 if git_workdir == workspace.workspace_root() {
52 return true;
53 }
54 // Colocated workspace should have ".git" directory, file, or symlink. Compare
55 // its parent as the git_workdir might be resolved from the real ".git" path.
56 let Ok(dot_git_path) = workspace.workspace_root().join(".git").canonicalize() else {
57 return false;
58 };
59 git_workdir.canonicalize().ok().as_deref() == dot_git_path.parent()
60}
61
62fn terminal_get_username(ui: &Ui, url: &str) -> Option<String> {
63 ui.prompt(&format!("Username for {url}")).ok()
64}
65
66fn terminal_get_pw(ui: &Ui, url: &str) -> Option<String> {
67 ui.prompt_password(&format!("Passphrase for {url}: ")).ok()
68}
69
70fn pinentry_get_pw(url: &str) -> Option<String> {
71 // https://www.gnupg.org/documentation/manuals/assuan/Server-responses.html#Server-responses
72 fn decode_assuan_data(encoded: &str) -> Option<String> {
73 let encoded = encoded.as_bytes();
74 let mut decoded = Vec::with_capacity(encoded.len());
75 let mut i = 0;
76 while i < encoded.len() {
77 if encoded[i] != b'%' {
78 decoded.push(encoded[i]);
79 i += 1;
80 continue;
81 }
82 i += 1;
83 let byte =
84 u8::from_str_radix(std::str::from_utf8(encoded.get(i..i + 2)?).ok()?, 16).ok()?;
85 decoded.push(byte);
86 i += 2;
87 }
88 String::from_utf8(decoded).ok()
89 }
90
91 let mut pinentry = std::process::Command::new("pinentry")
92 .stdin(Stdio::piped())
93 .stdout(Stdio::piped())
94 .spawn()
95 .ok()?;
96 let mut interact = || -> std::io::Result<_> {
97 #[rustfmt::skip]
98 let req = format!(
99 "SETTITLE jj passphrase\n\
100 SETDESC Enter passphrase for {url}\n\
101 SETPROMPT Passphrase:\n\
102 GETPIN\n"
103 );
104 pinentry.stdin.take().unwrap().write_all(req.as_bytes())?;
105 let mut out = String::new();
106 pinentry.stdout.take().unwrap().read_to_string(&mut out)?;
107 Ok(out)
108 };
109 let maybe_out = interact();
110 _ = pinentry.wait();
111 for line in maybe_out.ok()?.split('\n') {
112 if !line.starts_with("D ") {
113 continue;
114 }
115 let (_, encoded) = line.split_at(2);
116 return decode_assuan_data(encoded);
117 }
118 None
119}
120
121#[tracing::instrument]
122fn get_ssh_keys(_username: &str) -> Vec<PathBuf> {
123 let mut paths = vec![];
124 if let Some(home_dir) = dirs::home_dir() {
125 let ssh_dir = Path::new(&home_dir).join(".ssh");
126 for filename in ["id_ed25519_sk", "id_ed25519", "id_rsa"] {
127 let key_path = ssh_dir.join(filename);
128 if key_path.is_file() {
129 tracing::info!(path = ?key_path, "found ssh key");
130 paths.push(key_path);
131 }
132 }
133 }
134 if paths.is_empty() {
135 tracing::info!("no ssh key found");
136 }
137 paths
138}
139
140// Based on Git's implementation: https://github.com/git/git/blob/43072b4ca132437f21975ac6acc6b72dc22fd398/sideband.c#L178
141pub struct GitSidebandProgressMessageWriter {
142 display_prefix: &'static [u8],
143 suffix: &'static [u8],
144 scratch: Vec<u8>,
145}
146
147impl GitSidebandProgressMessageWriter {
148 pub fn new(ui: &Ui) -> Self {
149 let is_terminal = ui.use_progress_indicator();
150
151 GitSidebandProgressMessageWriter {
152 display_prefix: "remote: ".as_bytes(),
153 suffix: if is_terminal { "\x1B[K" } else { " " }.as_bytes(),
154 scratch: Vec::new(),
155 }
156 }
157
158 pub fn write(&mut self, ui: &Ui, progress_message: &[u8]) -> std::io::Result<()> {
159 let mut index = 0;
160 // Append a suffix to each nonempty line to clear the end of the screen line.
161 loop {
162 let Some(i) = progress_message[index..]
163 .iter()
164 .position(|&c| c == b'\r' || c == b'\n')
165 .map(|i| index + i)
166 else {
167 break;
168 };
169 let line_length = i - index;
170
171 // For messages sent across the packet boundary, there would be a nonempty
172 // "scratch" buffer from last call of this function, and there may be a leading
173 // CR/LF in this message. For this case we should add a clear-to-eol suffix to
174 // clean leftover letters we previously have written on the same line.
175 if !self.scratch.is_empty() && line_length == 0 {
176 self.scratch.extend_from_slice(self.suffix);
177 }
178
179 if self.scratch.is_empty() {
180 self.scratch.extend_from_slice(self.display_prefix);
181 }
182
183 // Do not add the clear-to-eol suffix to empty lines:
184 // For progress reporting we may receive a bunch of percentage updates
185 // followed by '\r' to remain on the same line, and at the end receive a single
186 // '\n' to move to the next line. We should preserve the final
187 // status report line by not appending clear-to-eol suffix to this single line
188 // break.
189 if line_length > 0 {
190 self.scratch.extend_from_slice(&progress_message[index..i]);
191 self.scratch.extend_from_slice(self.suffix);
192 }
193 self.scratch.extend_from_slice(&progress_message[i..i + 1]);
194
195 ui.status().write_all(&self.scratch)?;
196 self.scratch.clear();
197
198 index = i + 1;
199 }
200
201 // Add leftover message to "scratch" buffer to be printed in next call.
202 if index < progress_message.len() && progress_message[index] != 0 {
203 if self.scratch.is_empty() {
204 self.scratch.extend_from_slice(self.display_prefix);
205 }
206 self.scratch.extend_from_slice(&progress_message[index..]);
207 }
208
209 Ok(())
210 }
211
212 pub fn flush(&mut self, ui: &Ui) -> std::io::Result<()> {
213 if !self.scratch.is_empty() {
214 self.scratch.push(b'\n');
215 ui.status().write_all(&self.scratch)?;
216 self.scratch.clear();
217 }
218
219 Ok(())
220 }
221}
222
223type SidebandProgressCallback<'a> = &'a mut dyn FnMut(&[u8]);
224
225pub fn with_remote_git_callbacks<T>(
226 ui: &Ui,
227 sideband_progress_callback: Option<SidebandProgressCallback<'_>>,
228 f: impl FnOnce(git::RemoteCallbacks<'_>) -> T,
229) -> T {
230 let mut callbacks = git::RemoteCallbacks::default();
231 let mut progress_callback = None;
232 if let Some(mut output) = ui.progress_output() {
233 let mut progress = Progress::new(Instant::now());
234 progress_callback = Some(move |x: &git::Progress| {
235 _ = progress.update(Instant::now(), x, &mut output);
236 });
237 }
238 callbacks.progress = progress_callback
239 .as_mut()
240 .map(|x| x as &mut dyn FnMut(&git::Progress));
241 callbacks.sideband_progress = sideband_progress_callback.map(|x| x as &mut dyn FnMut(&[u8]));
242 let mut get_ssh_keys = get_ssh_keys; // Coerce to unit fn type
243 callbacks.get_ssh_keys = Some(&mut get_ssh_keys);
244 let mut get_pw =
245 |url: &str, _username: &str| pinentry_get_pw(url).or_else(|| terminal_get_pw(ui, url));
246 callbacks.get_password = Some(&mut get_pw);
247 let mut get_user_pw =
248 |url: &str| Some((terminal_get_username(ui, url)?, terminal_get_pw(ui, url)?));
249 callbacks.get_username_password = Some(&mut get_user_pw);
250 f(callbacks)
251}
252
253pub fn print_git_import_stats(
254 ui: &mut Ui,
255 repo: &dyn Repo,
256 stats: &GitImportStats,
257 show_ref_stats: bool,
258) -> Result<(), CommandError> {
259 let Some(mut formatter) = ui.status_formatter() else {
260 return Ok(());
261 };
262 if show_ref_stats {
263 let refs_stats = stats
264 .changed_remote_refs
265 .iter()
266 .map(|(ref_name, (remote_ref, ref_target))| {
267 RefStatus::new(ref_name, remote_ref, ref_target, repo)
268 })
269 .collect_vec();
270
271 let has_both_ref_kinds = refs_stats
272 .iter()
273 .any(|x| matches!(x.ref_kind, RefKind::Branch))
274 && refs_stats
275 .iter()
276 .any(|x| matches!(x.ref_kind, RefKind::Tag));
277
278 let max_width = refs_stats.iter().map(|x| x.ref_name.width()).max();
279 if let Some(max_width) = max_width {
280 for status in refs_stats {
281 status.output(max_width, has_both_ref_kinds, &mut *formatter)?;
282 }
283 }
284 }
285
286 if !stats.abandoned_commits.is_empty() {
287 writeln!(
288 formatter,
289 "Abandoned {} commits that are no longer reachable.",
290 stats.abandoned_commits.len()
291 )?;
292 }
293
294 Ok(())
295}
296
297struct RefStatus {
298 ref_kind: RefKind,
299 ref_name: String,
300 tracking_status: TrackingStatus,
301 import_status: ImportStatus,
302}
303
304impl RefStatus {
305 fn new(
306 ref_name: &RefName,
307 remote_ref: &RemoteRef,
308 ref_target: &RefTarget,
309 repo: &dyn Repo,
310 ) -> Self {
311 let (ref_name, ref_kind, tracking_status) = match ref_name {
312 RefName::RemoteBranch { branch, remote } => (
313 format!("{branch}@{remote}"),
314 RefKind::Branch,
315 if repo.view().get_remote_branch(branch, remote).is_tracking() {
316 TrackingStatus::Tracked
317 } else {
318 TrackingStatus::Untracked
319 },
320 ),
321 RefName::Tag(tag) => (tag.clone(), RefKind::Tag, TrackingStatus::NotApplicable),
322 RefName::LocalBranch(branch) => {
323 (branch.clone(), RefKind::Branch, TrackingStatus::Tracked)
324 }
325 };
326
327 let import_status = match (remote_ref.target.is_absent(), ref_target.is_absent()) {
328 (true, false) => ImportStatus::New,
329 (false, true) => ImportStatus::Deleted,
330 _ => ImportStatus::Updated,
331 };
332
333 Self {
334 ref_name,
335 tracking_status,
336 import_status,
337 ref_kind,
338 }
339 }
340
341 fn output(
342 &self,
343 max_ref_name_width: usize,
344 has_both_ref_kinds: bool,
345 out: &mut dyn Formatter,
346 ) -> std::io::Result<()> {
347 let tracking_status = match self.tracking_status {
348 TrackingStatus::Tracked => "tracked",
349 TrackingStatus::Untracked => "untracked",
350 TrackingStatus::NotApplicable => "",
351 };
352
353 let import_status = match self.import_status {
354 ImportStatus::New => "new",
355 ImportStatus::Deleted => "deleted",
356 ImportStatus::Updated => "updated",
357 };
358
359 let ref_name_display_width = self.ref_name.width();
360 let pad_width = max_ref_name_width.saturating_sub(ref_name_display_width);
361 let padded_ref_name = format!("{}{:>pad_width$}", self.ref_name, "", pad_width = pad_width);
362
363 let ref_kind = match self.ref_kind {
364 RefKind::Branch => "branch: ",
365 RefKind::Tag if !has_both_ref_kinds => "tag: ",
366 RefKind::Tag => "tag: ",
367 };
368
369 write!(out, "{ref_kind}")?;
370 write!(out.labeled("branch"), "{padded_ref_name}")?;
371 writeln!(out, " [{import_status}] {tracking_status}")
372 }
373}
374
375enum RefKind {
376 Branch,
377 Tag,
378}
379
380enum TrackingStatus {
381 Tracked,
382 Untracked,
383 NotApplicable, // for tags
384}
385
386enum ImportStatus {
387 New,
388 Deleted,
389 Updated,
390}
391
392pub fn print_failed_git_export(
393 ui: &Ui,
394 failed_branches: &[FailedRefExport],
395) -> Result<(), std::io::Error> {
396 if !failed_branches.is_empty() {
397 writeln!(ui.warning_default(), "Failed to export some branches:")?;
398 let mut formatter = ui.stderr_formatter();
399 for FailedRefExport { name, reason } in failed_branches {
400 write!(formatter, " ")?;
401 write!(formatter.labeled("branch"), "{name}")?;
402 for err in iter::successors(Some(reason as &dyn error::Error), |err| err.source()) {
403 write!(formatter, ": {err}")?;
404 }
405 writeln!(formatter)?;
406 }
407 drop(formatter);
408 if failed_branches
409 .iter()
410 .any(|failed| matches!(failed.reason, FailedRefExportReason::FailedToSet(_)))
411 {
412 if let Some(mut writer) = ui.hint_default() {
413 writeln!(
414 writer,
415 r#"Git doesn't allow a branch name that looks like a parent directory of
416another (e.g. `foo` and `foo/bar`). Try to rename the branches that failed to
417export or their "parent" branches."#,
418 )?;
419 }
420 }
421 }
422 Ok(())
423}
424
425/// Expands "~/" to "$HOME/" as Git seems to do for e.g. core.excludesFile.
426pub fn expand_git_path(path_str: &str) -> PathBuf {
427 if let Some(remainder) = path_str.strip_prefix("~/") {
428 if let Ok(home_dir_str) = std::env::var("HOME") {
429 return PathBuf::from(home_dir_str).join(remainder);
430 }
431 }
432 PathBuf::from(path_str)
433}