Skip to content

Commit 644744c

Browse files
committedJan 18, 2025
git: spawn a separate git process for network operations
Reasoning: `jj` fails to push/fetch over ssh depending on the system. Issue jj-vcs#4979 lists over 20 related issues on this and proposes spawning a `git` subprocess for tasks related to the network (in fact, just push/fetch are enough). This PR implements this. Users can either enable shelling out to git in a config file: ```toml [git] subprocess = true ``` Implementation Details: This PR implements shelling out to `git` via `std::process::Command`. There are 2 sharp edges with the patch: - it relies on having to parse out git errors to match the error codes (and parsing git2's errors in one particular instance to match the error behaviour). This seems mostly unavoidable - to ensure matching behaviour with git2, the tests are maintained across the two implementations. This is done using test_case, as with the rest of the codebase Testing: Run the rust tests: ``` $ cargo test ``` Build: ``` $ cargo build ``` Clone a private repo: ``` $ path/to/jj git clone --config='git.subprocess=true' <REPO_SSH_URL> ``` Create new commit and push ``` $ echo "TEST" > this_is_a_test_file.txt $ path/to/jj describe -m 'test commit' $ path/to/jj git push --config='git.subprocess=true' -b <branch> ``` <!-- There's no need to add anything here, but feel free to add a personal message. Please describe the changes in this PR in the commit message(s) instead, with each commit representing one logical change. Address code review comments by rewriting the commits rather than adding commits on top. Use force-push when pushing the updated commits (`jj git push` does that automatically when you rewrite commits). Merge the PR at will once it's been approved. See https://github.com/jj-vcs/jj/blob/main/docs/contributing.md for details. Note that you need to sign Google's CLA to contribute. --> Issues Closed With a grain of salt, but most of these problems should be fixed (or at least checked if they are fixed). They are the ones listed in jj-vcs#4979 . SSH: - jj-vcs#63 - jj-vcs#440 - jj-vcs#1455 - jj-vcs#1507 - jj-vcs#2931 - jj-vcs#2958 - jj-vcs#3322 - jj-vcs#4101 - jj-vcs#4333 - jj-vcs#4386 - jj-vcs#4488 - jj-vcs#4591 - jj-vcs#4802 - jj-vcs#4870 - jj-vcs#4937 - jj-vcs#4978 - jj-vcs#5120 - jj-vcs#5166 Clone/fetch/push/pull: - jj-vcs#360 - jj-vcs#1278 - jj-vcs#1957 - jj-vcs#2295 - jj-vcs#3851 - jj-vcs#4177 - jj-vcs#4682 - jj-vcs#4719 - jj-vcs#4889 - jj-vcs#5147 - jj-vcs#5238 Notable Holdouts: - Interactive HTTP authentication (jj-vcs#401, jj-vcs#469) - libssh2-sys dependency on windows problem (can only be removed if/when we get rid of libgit2): jj-vcs#3984
1 parent 4e367af commit 644744c

18 files changed

+2644
-283
lines changed
 

‎.github/workflows/build.yml

+11-1
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,19 @@ jobs:
8686
tool: nextest
8787
- name: Build
8888
run: cargo build --workspace --all-targets --verbose ${{ matrix.cargo_flags }}
89-
- name: Test
89+
- name: Test [non-windows]
90+
if: ${{ matrix.os != 'windows-latest' }}
91+
run: |
92+
export TEST_GIT_EXECUTABLE_PATH="$(which git)";
93+
cargo nextest run --workspace --all-targets --verbose ${{ matrix.cargo_flags }}
94+
env:
95+
RUST_BACKTRACE: 1
96+
CARGO_TERM_COLOR: always
97+
- name: Test [windows]
98+
if: ${{ matrix.os == 'windows-latest' }}
9099
run: cargo nextest run --workspace --all-targets --verbose ${{ matrix.cargo_flags }}
91100
env:
101+
TEST_GIT_EXECUTABLE_PATH: 'C:\Program Files\Git\bin\git.exe'
92102
RUST_BACKTRACE: 1
93103
CARGO_TERM_COLOR: always
94104

‎CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6262

6363
### New features
6464

65+
* `jj git {push,clone,fetch}` can now spawn an external `git` subprocess, via the `git.subprocess = true` config knob.
66+
6567
* `jj evolog` and `jj op log` now accept `--reversed`.
6668

6769
* `jj restore` now supports `-i`/`--interactive` selection.
@@ -100,6 +102,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
100102

101103
### Fixed bugs
102104

105+
* Fixes SSH bugs when interacting with Git remotes due to `libgit2`'s limitations.
106+
[#4979](https://github.com/jj-vcs/jj/issues/4979)
107+
103108
* Fixed diff selection by external tools with `jj split`/`commit -i FILESETS`.
104109
[#5252](https://github.com/jj-vcs/jj/issues/5252)
105110

‎cli/src/commands/git/clone.rs

+10-3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use std::num::NonZeroU32;
1919
use std::path::Path;
2020

2121
use jj_lib::git;
22+
use jj_lib::git::GitApiSettings;
2223
use jj_lib::git::GitFetchError;
2324
use jj_lib::git::GitFetchStats;
2425
use jj_lib::repo::Repo;
@@ -34,6 +35,7 @@ use crate::command_error::user_error_with_message;
3435
use crate::command_error::CommandError;
3536
use crate::commands::git::maybe_add_gitignore;
3637
use crate::git_util::absolute_git_url;
38+
use crate::git_util::get_config_git_api_settings;
3739
use crate::git_util::get_git_repo;
3840
use crate::git_util::map_git_error;
3941
use crate::git_util::print_git_import_stats;
@@ -122,9 +124,11 @@ pub fn cmd_git_clone(
122124
// `/some/path/.`
123125
let canonical_wc_path = dunce::canonicalize(&wc_path)
124126
.map_err(|err| user_error_with_message(format!("Failed to create {wc_path_str}"), err))?;
127+
let git_api_settings = get_config_git_api_settings(command)?;
125128
let clone_result = do_git_clone(
126129
ui,
127130
command,
131+
git_api_settings,
128132
args.colocate,
129133
CloneArgs {
130134
depth: args.depth,
@@ -186,6 +190,7 @@ pub fn cmd_git_clone(
186190
fn do_git_clone(
187191
ui: &mut Ui,
188192
command: &CommandHelper,
193+
git_api_settings: GitApiSettings,
189194
colocate: bool,
190195
clone_args: CloneArgs<'_>,
191196
) -> Result<(WorkspaceCommandHelper, GitFetchStats), CommandError> {
@@ -214,10 +219,11 @@ fn do_git_clone(
214219
let git_settings = workspace_command.settings().git_settings()?;
215220
let mut fetch_tx = workspace_command.start_transaction();
216221

217-
let stats = with_remote_git_callbacks(ui, None, |cb| {
222+
let stats = with_remote_git_callbacks(ui, None, git_api_settings.subprocess, |cb| {
218223
git::fetch(
219224
fetch_tx.repo_mut(),
220225
&git_repo,
226+
&git_api_settings,
221227
remote_name,
222228
&[StringPattern::everything()],
223229
cb,
@@ -226,11 +232,12 @@ fn do_git_clone(
226232
)
227233
})
228234
.map_err(|err| match err {
229-
GitFetchError::NoSuchRemote(_) => {
230-
panic!("shouldn't happen as we just created the git remote")
235+
GitFetchError::NoSuchRemote(repo_name) => {
236+
user_error(format!("Could not find repository at '{repo_name}'"))
231237
}
232238
GitFetchError::GitImportError(err) => CommandError::from(err),
233239
GitFetchError::InternalGitError(err) => map_git_error(err),
240+
GitFetchError::Subprocess(err) => user_error(err),
234241
GitFetchError::InvalidBranchPattern => {
235242
unreachable!("we didn't provide any globs")
236243
}

‎cli/src/commands/git/fetch.rs

+10-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use crate::cli_util::CommandHelper;
2323
use crate::command_error::CommandError;
2424
use crate::commands::git::get_single_remote;
2525
use crate::complete;
26+
use crate::git_util::get_config_git_api_settings;
2627
use crate::git_util::get_git_repo;
2728
use crate::git_util::git_fetch;
2829
use crate::ui::Ui;
@@ -69,6 +70,7 @@ pub fn cmd_git_fetch(
6970
args: &GitFetchArgs,
7071
) -> Result<(), CommandError> {
7172
let mut workspace_command = command.workspace_helper(ui)?;
73+
let git_api_settings = get_config_git_api_settings(command)?;
7274
let git_repo = get_git_repo(workspace_command.repo().store())?;
7375
let remotes = if args.all_remotes {
7476
get_all_remotes(&git_repo)?
@@ -78,7 +80,14 @@ pub fn cmd_git_fetch(
7880
args.remotes.clone()
7981
};
8082
let mut tx = workspace_command.start_transaction();
81-
git_fetch(ui, &mut tx, &git_repo, &remotes, &args.branch)?;
83+
git_fetch(
84+
ui,
85+
&mut tx,
86+
&git_repo,
87+
git_api_settings,
88+
&remotes,
89+
&args.branch,
90+
)?;
8291
tx.finish(
8392
ui,
8493
format!("fetch from git remote(s) {}", remotes.iter().join(",")),

‎cli/src/commands/git/push.rs

+18-3
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ use crate::command_error::CommandError;
5454
use crate::commands::git::get_single_remote;
5555
use crate::complete;
5656
use crate::formatter::Formatter;
57+
use crate::git_util::get_config_git_api_settings;
5758
use crate::git_util::get_git_repo;
5859
use crate::git_util::map_git_error;
5960
use crate::git_util::with_remote_git_callbacks;
@@ -178,6 +179,7 @@ pub fn cmd_git_push(
178179
args: &GitPushArgs,
179180
) -> Result<(), CommandError> {
180181
let mut workspace_command = command.workspace_helper(ui)?;
182+
let git_api_settings = get_config_git_api_settings(command)?;
181183
let git_repo = get_git_repo(workspace_command.repo().store())?;
182184

183185
let remote = if let Some(name) = &args.remote {
@@ -357,9 +359,22 @@ pub fn cmd_git_push(
357359
let mut sideband_progress_callback = |progress_message: &[u8]| {
358360
_ = writer.write(ui, progress_message);
359361
};
360-
with_remote_git_callbacks(ui, Some(&mut sideband_progress_callback), |cb| {
361-
git::push_branches(tx.repo_mut(), &git_repo, &remote, &targets, cb)
362-
})
362+
363+
with_remote_git_callbacks(
364+
ui,
365+
Some(&mut sideband_progress_callback),
366+
git_api_settings.subprocess,
367+
|cb| {
368+
git::push_branches(
369+
tx.repo_mut(),
370+
&git_repo,
371+
&git_api_settings,
372+
&remote,
373+
&targets,
374+
cb,
375+
)
376+
},
377+
)
363378
.map_err(|err| match err {
364379
GitPushError::InternalGitError(err) => map_git_error(err),
365380
GitPushError::RefInUnexpectedLocation(refs) => user_error_with_hint(

‎cli/src/config-schema.json

+9
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,15 @@
377377
"type": "boolean",
378378
"description": "Whether jj should sign commits before pushing",
379379
"default": "false"
380+
},
381+
"subprocess": {
382+
"type": "boolean",
383+
"description": "Whether jj spawns a git subprocess for network operations (push/fetch/clone)",
384+
"default": false
385+
},
386+
"executable-path": {
387+
"type": "string",
388+
"description": "Path to the git executable"
380389
}
381390
}
382391
},

‎cli/src/git_util.rs

+46-21
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@ use std::time::Instant;
2929
use crossterm::terminal::Clear;
3030
use crossterm::terminal::ClearType;
3131
use itertools::Itertools;
32+
use jj_lib::config::ConfigGetError;
3233
use jj_lib::fmt_util::binary_prefix;
3334
use jj_lib::git;
3435
use jj_lib::git::FailedRefExport;
3536
use jj_lib::git::FailedRefExportReason;
37+
use jj_lib::git::GitApiSettings;
3638
use jj_lib::git::GitFetchError;
3739
use jj_lib::git::GitImportStats;
3840
use jj_lib::git::RefName;
@@ -47,6 +49,7 @@ use jj_lib::workspace::Workspace;
4749
use unicode_width::UnicodeWidthStr;
4850

4951
use crate::cleanup_guard::CleanupGuard;
52+
use crate::cli_util::CommandHelper;
5053
use crate::cli_util::WorkspaceCommandTransaction;
5154
use crate::command_error::cli_error;
5255
use crate::command_error::user_error;
@@ -282,29 +285,40 @@ type SidebandProgressCallback<'a> = &'a mut dyn FnMut(&[u8]);
282285
pub fn with_remote_git_callbacks<T>(
283286
ui: &Ui,
284287
sideband_progress_callback: Option<SidebandProgressCallback<'_>>,
288+
subprocess: bool,
285289
f: impl FnOnce(git::RemoteCallbacks<'_>) -> T,
286290
) -> T {
287-
let mut callbacks = git::RemoteCallbacks::default();
288-
let mut progress_callback = None;
289-
if let Some(mut output) = ui.progress_output() {
290-
let mut progress = Progress::new(Instant::now());
291-
progress_callback = Some(move |x: &git::Progress| {
292-
_ = progress.update(Instant::now(), x, &mut output);
293-
});
291+
if subprocess {
292+
// TODO: with git2, we are able to display progress from the data that is given
293+
// With the git processes themselves, this is significantly harder, as it
294+
// requires parsing the output directly
295+
//
296+
// In any case, this would be the place to add that funcionalty
297+
f(git::RemoteCallbacks::default())
298+
} else {
299+
let mut callbacks = git::RemoteCallbacks::default();
300+
let mut progress_callback = None;
301+
if let Some(mut output) = ui.progress_output() {
302+
let mut progress = Progress::new(Instant::now());
303+
progress_callback = Some(move |x: &git::Progress| {
304+
_ = progress.update(Instant::now(), x, &mut output);
305+
});
306+
}
307+
callbacks.progress = progress_callback
308+
.as_mut()
309+
.map(|x| x as &mut dyn FnMut(&git::Progress));
310+
callbacks.sideband_progress =
311+
sideband_progress_callback.map(|x| x as &mut dyn FnMut(&[u8]));
312+
let mut get_ssh_keys = get_ssh_keys; // Coerce to unit fn type
313+
callbacks.get_ssh_keys = Some(&mut get_ssh_keys);
314+
let mut get_pw =
315+
|url: &str, _username: &str| pinentry_get_pw(url).or_else(|| terminal_get_pw(ui, url));
316+
callbacks.get_password = Some(&mut get_pw);
317+
let mut get_user_pw =
318+
|url: &str| Some((terminal_get_username(ui, url)?, terminal_get_pw(ui, url)?));
319+
callbacks.get_username_password = Some(&mut get_user_pw);
320+
f(callbacks)
294321
}
295-
callbacks.progress = progress_callback
296-
.as_mut()
297-
.map(|x| x as &mut dyn FnMut(&git::Progress));
298-
callbacks.sideband_progress = sideband_progress_callback.map(|x| x as &mut dyn FnMut(&[u8]));
299-
let mut get_ssh_keys = get_ssh_keys; // Coerce to unit fn type
300-
callbacks.get_ssh_keys = Some(&mut get_ssh_keys);
301-
let mut get_pw =
302-
|url: &str, _username: &str| pinentry_get_pw(url).or_else(|| terminal_get_pw(ui, url));
303-
callbacks.get_password = Some(&mut get_pw);
304-
let mut get_user_pw =
305-
|url: &str| Some((terminal_get_username(ui, url)?, terminal_get_pw(ui, url)?));
306-
callbacks.get_username_password = Some(&mut get_user_pw);
307-
f(callbacks)
308322
}
309323

310324
pub fn print_git_import_stats(
@@ -629,16 +643,18 @@ pub fn git_fetch(
629643
ui: &mut Ui,
630644
tx: &mut WorkspaceCommandTransaction,
631645
git_repo: &git2::Repository,
646+
git_api_settings: GitApiSettings,
632647
remotes: &[String],
633648
branch: &[StringPattern],
634649
) -> Result<(), CommandError> {
635650
let git_settings = tx.settings().git_settings()?;
636651

637652
for remote in remotes {
638-
let stats = with_remote_git_callbacks(ui, None, |cb| {
653+
let stats = with_remote_git_callbacks(ui, None, git_api_settings.subprocess, |cb| {
639654
git::fetch(
640655
tx.repo_mut(),
641656
git_repo,
657+
&git_api_settings,
642658
remote,
643659
branch,
644660
cb,
@@ -705,6 +721,15 @@ fn warn_if_branches_not_found(
705721
Ok(())
706722
}
707723

724+
pub(crate) fn get_config_git_api_settings(
725+
command: &CommandHelper,
726+
) -> Result<GitApiSettings, ConfigGetError> {
727+
Ok(GitApiSettings {
728+
subprocess: command.settings().get_bool("git.subprocess")?,
729+
executable_path: command.settings().get("git.executable-path")?,
730+
})
731+
}
732+
708733
#[cfg(test)]
709734
mod tests {
710735
use std::path::MAIN_SEPARATOR;

‎cli/tests/cli-reference@.md.snap

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
source: cli/tests/test_generate_md_cli_help.rs
33
description: "AUTO-GENERATED FILE, DO NOT EDIT. This cli reference is generated by a test as an `insta` snapshot. MkDocs includes this snapshot from docs/cli-reference.md."
4+
snapshot_kind: text
45
---
56
<!-- BEGIN MARKDOWN-->
67

‎cli/tests/common/mod.rs

+13
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ impl Default for TestEnvironment {
8080
'format_time_range(time_range)' = 'time_range.start() ++ " - " ++ time_range.end()'
8181
"#,
8282
);
83+
8384
env
8485
}
8586
}
@@ -280,6 +281,18 @@ impl TestEnvironment {
280281
self.env_vars.insert(key.into(), val.into());
281282
}
282283

284+
pub fn set_up_git_subprocessing(&self) {
285+
self.add_config("git.subprocess = true");
286+
287+
// add a git path from env: this is only used if git.subprocess = true
288+
if let Ok(git_executable_path) = std::env::var("TEST_GIT_EXECUTABLE_PATH") {
289+
self.add_config(format!(
290+
"git.executable-path = '{}'",
291+
git_executable_path.trim()
292+
));
293+
}
294+
}
295+
283296
pub fn current_operation_id(&self, repo_path: &Path) -> String {
284297
let id_and_newline =
285298
self.jj_cmd_success(repo_path, &["debug", "operation", "--display=id"]);

0 commit comments

Comments
 (0)