feat(submodules): support auth and checkout submodule revisions
Add credential-aware submodule operations and a command to checkout a specific
tag or commit in a submodule without staging the parent repository.
- Backend (src-tauri):
- Export checkout_submodule_revision and implement checkout_revision which
validates tag vs commit inputs, verifies refs locally, and checks out the
submodule in detached mode without modifying the parent's index.
- Add optional username/password parameters to add_submodule and submodule_action
flows. Implement submodule_git to call run_git_authenticated when credentials
are supplied and classify auth failures by prefixing errors with "AUTH_FAILED:".
- Wire authenticated variants (operate_authenticated, add_authenticated) and
update fetch/update actions to use credentials where needed.
- Add unit tests covering authenticated submodule commands, auth failure
classification, and checkout-by-tag/commit behavior.
- Frontend:
- App.svelte: introduce credential prompt flow (withSubmoduleCredentials,
submit/cancel handlers), surface credential dialog on auth failures, and
wire credentialed calls for initialize/add/update/fetch operations. Hook up
checkoutSubmoduleRevision and listTags to the submodule dialog.
- SubmoduleDialog.svelte: add UI for selecting destination folder, loading
tags and checking out revisions; expose fetch action.
- CredentialDialog.svelte: include "submodule" action and adjust labels.
- Docs:
- README: document "Change commit or tag" and "Fetch tags & commits" behaviors.
The commit focuses only on enabling credentialed submodule interactions and
safe local checkouts of tags/commits; no other git behavior changes are made.
This commit is contained in:
@@ -185,7 +185,19 @@ pub async fn list_submodules(path: String, recursive: bool) -> Result<Vec<GitSub
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn operate(repo: &Path, module_path: &str, action: &str, recursive: bool) -> Result<(), String> {
|
||||
operate_authenticated(repo, module_path, action, recursive, None, None)
|
||||
}
|
||||
|
||||
fn operate_authenticated(
|
||||
repo: &Path,
|
||||
module_path: &str,
|
||||
action: &str,
|
||||
recursive: bool,
|
||||
username: Option<&str>,
|
||||
password: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let module = list(repo, true)?
|
||||
.into_iter()
|
||||
.find(|m| m.path == module_path)
|
||||
@@ -214,7 +226,18 @@ fn operate(repo: &Path, module_path: &str, action: &str, recursive: bool) -> Res
|
||||
args.push("--recursive");
|
||||
}
|
||||
args.extend(["--", module.relative_path.as_str()]);
|
||||
submodule_git(owner, &args)?;
|
||||
submodule_git(owner, &args, username, password)?;
|
||||
}
|
||||
"fetch" => {
|
||||
if module.local_commit.is_none() {
|
||||
return Err("Initialize the submodule first.".into());
|
||||
}
|
||||
submodule_git(
|
||||
Path::new(&module.full_path),
|
||||
&["fetch", "--tags"],
|
||||
username,
|
||||
password,
|
||||
)?;
|
||||
}
|
||||
"stage" => {
|
||||
if module.local_commit.is_none() {
|
||||
@@ -231,7 +254,7 @@ fn operate(repo: &Path, module_path: &str, action: &str, recursive: bool) -> Res
|
||||
args.push("--recursive");
|
||||
}
|
||||
args.extend(["--", module.relative_path.as_str()]);
|
||||
submodule_git(owner, &args)?;
|
||||
submodule_git(owner, &args, username, password)?;
|
||||
}
|
||||
_ => return Err("Unknown submodule action.".into()),
|
||||
}
|
||||
@@ -244,14 +267,103 @@ pub async fn submodule_action(
|
||||
module_path: String,
|
||||
action: String,
|
||||
recursive: bool,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
run_git_task("Could not update submodule", move || {
|
||||
operate(&resolve_repo(&path)?, &module_path, &action, recursive)
|
||||
operate_authenticated(
|
||||
&resolve_repo(&path)?,
|
||||
&module_path,
|
||||
&action,
|
||||
recursive,
|
||||
username.as_deref(),
|
||||
password.as_deref(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn checkout_revision(
|
||||
repo: &Path,
|
||||
module_path: &str,
|
||||
revision: &str,
|
||||
kind: &str,
|
||||
) -> Result<(), String> {
|
||||
let module = list(repo, true)?
|
||||
.into_iter()
|
||||
.find(|m| m.path == module_path)
|
||||
.ok_or("Submodule no longer exists. Refresh the list.")?;
|
||||
if module.local_commit.is_none() {
|
||||
return Err("Initialize the submodule first.".into());
|
||||
}
|
||||
if module.dirty || module.conflicted {
|
||||
return Err("Commit or stash local changes and resolve conflicts before changing the submodule revision.".into());
|
||||
}
|
||||
let target = Path::new(&module.full_path);
|
||||
let revision = revision.trim();
|
||||
let reference = match kind {
|
||||
"commit"
|
||||
if (4..=64).contains(&revision.len())
|
||||
&& revision.bytes().all(|c| c.is_ascii_hexdigit()) =>
|
||||
{
|
||||
revision.to_owned()
|
||||
}
|
||||
"tag" => {
|
||||
let reference = format!("refs/tags/{revision}");
|
||||
run_git(target, ["check-ref-format", &reference])?;
|
||||
reference
|
||||
}
|
||||
_ => return Err("Choose a tag or enter a valid commit hash.".into()),
|
||||
};
|
||||
let hash = run_git(
|
||||
target,
|
||||
[
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
"--end-of-options",
|
||||
&format!("{reference}^{{commit}}"),
|
||||
],
|
||||
)
|
||||
.map_err(|_| "Commit or tag was not found locally. Fetch tags and commits first.".to_owned())?;
|
||||
let hash = String::from_utf8_lossy(&hash);
|
||||
run_git(
|
||||
target,
|
||||
[
|
||||
"checkout",
|
||||
"--detach",
|
||||
"--no-recurse-submodules",
|
||||
hash.trim(),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn checkout_submodule_revision(
|
||||
path: String,
|
||||
module_path: String,
|
||||
revision: String,
|
||||
kind: String,
|
||||
) -> Result<(), String> {
|
||||
run_git_task("Could not change submodule revision", move || {
|
||||
checkout_revision(&resolve_repo(&path)?, &module_path, &revision, &kind)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn add(repo: &Path, url: &str, destination: &str, branch: Option<&str>) -> Result<(), String> {
|
||||
add_authenticated(repo, url, destination, branch, None, None)
|
||||
}
|
||||
|
||||
fn add_authenticated(
|
||||
repo: &Path,
|
||||
url: &str,
|
||||
destination: &str,
|
||||
branch: Option<&str>,
|
||||
username: Option<&str>,
|
||||
password: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
safe_path(repo, destination)?;
|
||||
if url.trim().is_empty() || url.starts_with('-') {
|
||||
return Err("Enter a valid repository URL.".into());
|
||||
@@ -262,11 +374,19 @@ fn add(repo: &Path, url: &str, destination: &str, branch: Option<&str>) -> Resul
|
||||
args.extend(["--branch", branch]);
|
||||
}
|
||||
args.extend(["--", url, destination]);
|
||||
submodule_git(repo, &args)?;
|
||||
submodule_git(repo, &args, username, password)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn submodule_git(repo: &Path, args: &[&str]) -> Result<(), String> {
|
||||
fn submodule_git(
|
||||
repo: &Path,
|
||||
args: &[&str],
|
||||
username: Option<&str>,
|
||||
password: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
if let (Some(username), Some(password)) = (username, password) {
|
||||
return super::run_git_authenticated(repo, args, username, password).map(|_| ());
|
||||
}
|
||||
let output = super::git_command()
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
@@ -277,7 +397,12 @@ fn submodule_git(repo: &Path, args: &[&str]) -> Result<(), String> {
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(super::command_output_details(&output))
|
||||
let details = super::command_output_details(&output);
|
||||
if super::is_auth_error(&details) {
|
||||
Err(format!("AUTH_FAILED:{details}"))
|
||||
} else {
|
||||
Err(details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,9 +412,18 @@ pub async fn add_submodule(
|
||||
url: String,
|
||||
destination: String,
|
||||
branch: Option<String>,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
run_git_task("Could not add submodule", move || {
|
||||
add(&resolve_repo(&path)?, &url, &destination, branch.as_deref())
|
||||
add_authenticated(
|
||||
&resolve_repo(&path)?,
|
||||
&url,
|
||||
&destination,
|
||||
branch.as_deref(),
|
||||
username.as_deref(),
|
||||
password.as_deref(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -345,6 +479,123 @@ mod tests {
|
||||
git(&parent, &["commit", "-qam", "submodule"]);
|
||||
Fixture(path)
|
||||
}
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn submodules_authenticated_commands_receive_askpass_credentials() {
|
||||
let f = fixture();
|
||||
let repo = f.0.join("parent");
|
||||
let probe = r#"alias.auth-probe=!test "$("$GIT_ASKPASS" Username)" = 'fixture-user' && test "$("$GIT_ASKPASS" Password)" = 'fixture-token'"#;
|
||||
submodule_git(
|
||||
&repo,
|
||||
&["-c", probe, "auth-probe"],
|
||||
Some("fixture-user"),
|
||||
Some("fixture-token"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn submodules_auth_failures_are_classified_for_the_login_dialog() {
|
||||
let f = fixture();
|
||||
let repo = f.0.join("parent");
|
||||
let probe = "alias.auth-probe=!echo 'fatal: could not read Username: terminal prompts disabled' >&2; exit 1";
|
||||
let error = submodule_git(&repo, &["-c", probe, "auth-probe"], None, None).unwrap_err();
|
||||
assert!(error.starts_with("AUTH_FAILED:"));
|
||||
let error = submodule_git(
|
||||
&repo,
|
||||
&["-c", probe, "auth-probe"],
|
||||
Some("user"),
|
||||
Some("token"),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.starts_with("AUTH_FAILED:"));
|
||||
let error = submodule_git(&repo, &["not-a-command"], None, None).unwrap_err();
|
||||
assert!(!error.starts_with("AUTH_FAILED:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submodules_checkout_tags_and_commits_without_staging_parent() {
|
||||
let f = fixture();
|
||||
let repo = f.0.join("parent");
|
||||
let child = repo.join("libs/with spaces");
|
||||
let original = list(&repo, true).unwrap()[0].recorded_commit.clone();
|
||||
git(&child, &["tag", "v1.0"]);
|
||||
fs::write(child.join("file.txt"), "version two\n").unwrap();
|
||||
git(
|
||||
&child,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-qam",
|
||||
"version two",
|
||||
],
|
||||
);
|
||||
git(
|
||||
&child,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"tag",
|
||||
"-a",
|
||||
"v2.0",
|
||||
"-m",
|
||||
"version two",
|
||||
],
|
||||
);
|
||||
let latest = list(&repo, true).unwrap()[0].local_commit.clone().unwrap();
|
||||
checkout_revision(&repo, "libs/with spaces", "v1.0", "tag").unwrap();
|
||||
assert_eq!(
|
||||
list(&repo, true).unwrap()[0].local_commit.as_deref(),
|
||||
Some(original.as_str())
|
||||
);
|
||||
checkout_revision(&repo, "libs/with spaces", "v2.0", "tag").unwrap();
|
||||
let module = list(&repo, true).unwrap().remove(0);
|
||||
assert_eq!(module.local_commit.as_deref(), Some(latest.as_str()));
|
||||
assert_eq!(module.recorded_commit, original);
|
||||
assert!(module.branch.is_none());
|
||||
checkout_revision(&repo, "libs/with spaces", &original[..8], "commit").unwrap();
|
||||
assert_eq!(
|
||||
list(&repo, true).unwrap()[0].local_commit.as_deref(),
|
||||
Some(original.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submodules_revision_rejects_unknown_refs_options_and_local_changes() {
|
||||
let f = fixture();
|
||||
let repo = f.0.join("parent");
|
||||
let child = repo.join("libs/with spaces");
|
||||
let before = list(&repo, true).unwrap()[0].local_commit.clone();
|
||||
for (revision, kind) in [
|
||||
("--force", "commit"),
|
||||
("HEAD~1", "commit"),
|
||||
("../bad", "tag"),
|
||||
("missing", "tag"),
|
||||
("deadbeef", "commit"),
|
||||
("main", "branch"),
|
||||
] {
|
||||
assert!(checkout_revision(&repo, "libs/with spaces", revision, kind).is_err());
|
||||
}
|
||||
assert_eq!(list(&repo, true).unwrap()[0].local_commit, before);
|
||||
git(&child, &["tag", "valid"]);
|
||||
fs::write(child.join("file.txt"), "local changes\n").unwrap();
|
||||
assert!(
|
||||
checkout_revision(&repo, "libs/with spaces", "valid", "tag")
|
||||
.unwrap_err()
|
||||
.contains("stash")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(child.join("file.txt")).unwrap(),
|
||||
"local changes\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submodules_list_clean_and_uninitialized() {
|
||||
let f = fixture();
|
||||
|
||||
@@ -10,7 +10,7 @@ use badge::set_sync_badge;
|
||||
use external_tools::{
|
||||
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
|
||||
};
|
||||
use git::submodules::{add_submodule, list_submodules, submodule_action};
|
||||
use git::submodules::{checkout_submodule_revision, add_submodule, list_submodules, submodule_action};
|
||||
use git::{
|
||||
SearchCancellationState, add_remote, add_to_gitignore, add_worktree, amend_commit,
|
||||
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
||||
@@ -366,6 +366,7 @@ async fn main() {
|
||||
list_submodules,
|
||||
add_submodule,
|
||||
submodule_action,
|
||||
checkout_submodule_revision,
|
||||
list_worktrees,
|
||||
add_worktree,
|
||||
remove_worktree,
|
||||
|
||||
Reference in New Issue
Block a user