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:
2026-09-17 19:39:40 +02:00
parent a9af02a697
commit b00e3e5c18
7 changed files with 433 additions and 29 deletions
+258 -7
View File
@@ -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();
+2 -1
View File
@@ -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,