Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
aeeba03
feat(git): implement support for private GitHub and GitLab repositories
May 11, 2026
480ba8e
feat: add support for configuring git credentials for private reposit…
May 11, 2026
2688621
Apply suggestions from code review
sstreichan May 12, 2026
f845882
refactor: simplify conditional checks and improve code readability in…
sstreichan May 13, 2026
a464a06
refactor: reorganize import statements in rust_cli.py and test_add_re…
sstreichan May 13, 2026
a683a64
fix: wip - security fixes for review issues
sstreichan May 14, 2026
01a0b83
fix: wip - security fixes for review issues
sstreichan May 14, 2026
ff90d19
feat(git): implement support for private GitHub and GitLab repositories
May 11, 2026
3c5feed
feat: add support for configuring git credentials for private reposit…
May 11, 2026
208d7a7
Apply suggestions from code review
sstreichan May 12, 2026
4ec6892
refactor: simplify conditional checks and improve code readability in…
sstreichan May 13, 2026
dacb089
refactor: reorganize import statements in rust_cli.py and test_add_re…
sstreichan May 13, 2026
e27b187
fix: wip - security fixes for review issues
sstreichan May 14, 2026
ede03cd
fix: wip - security fixes for review issues
sstreichan May 14, 2026
e0c439c
Merge branch 'volcengine:main' into PrivateGitRepositories
sstreichan May 22, 2026
1549a00
Merge branch 'volcengine:main' into main
sstreichan May 22, 2026
82bde18
fix(git-auth): use oauth2:token format for GitLab, migrate credential…
May 22, 2026
55e041e
fix(git-auth): use oauth2:token format for GitLab, migrate credential…
May 22, 2026
607ac3f
Merge branch 'main' into main
MaojiaSheng May 25, 2026
54b29e3
Merge branch 'volcengine:main' into main
sstreichan May 26, 2026
c458ed1
fix: resolve merge conflict in main.rs — keep profile:false from main
May 26, 2026
8c4377d
Merge PrivateGitRepositories into main
sstreichan May 26, 2026
4cee412
fix: mask tokens in git_accessor error messages to prevent credential…
May 26, 2026
2de988c
Merge branch 'volcengine:main' into main
sstreichan May 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/ov_cli/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ pub struct Config {
pub upload: UploadConfig,
#[serde(default, alias = "extra_header")]
pub extra_headers: Option<std::collections::HashMap<String, String>>,
#[serde(default)]
pub git_credentials: Option<std::collections::HashMap<String, String>>,
}

fn default_url() -> String {
Expand Down Expand Up @@ -92,6 +94,7 @@ impl Default for Config {
profile: false,
upload: UploadConfig::default(),
extra_headers: None,
git_credentials: None,
}
}
}
Expand Down
203 changes: 203 additions & 0 deletions crates/ov_cli/src/git_credentials.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
use url::Url;

/// Return `true` if the URL looks like a cloneable git URL.
pub fn is_git_url(url: &str) -> bool {
url.starts_with("https://")
|| url.starts_with("http://")
|| url.starts_with("git@")
|| url.starts_with("git://")
|| url.starts_with("ssh://")
}

/// Inject a personal access token into an HTTPS/HTTP git URL.
///
/// GitHub hosts use bare token as userinfo (`token@host`).
/// GitLab and other hosts use `oauth2:token@host` per the GitLab PAT convention.
/// SSH (`git@`, `ssh://`) and `git://` URLs are returned unchanged.
pub fn inject_token(url: &str, token: &str) -> String {
if !url.starts_with("https://") && !url.starts_with("http://") {
return url.to_string();
}

let Ok(mut parsed) = Url::parse(url) else {
return url.to_string();
};

let hostname = parsed.host_str().unwrap_or("").to_lowercase();
if hostname.contains("github") {
let _ = parsed.set_username(token);
let _ = parsed.set_password(None);
} else {
let _ = parsed.set_username("oauth2");
let _ = parsed.set_password(Some(token));
}

parsed.into()
}

/// Mask any embedded token in a URL for safe logging.
///
/// Replaces the userinfo portion with `***`.
pub fn mask_token_in_url(url: &str) -> String {
if !url.starts_with("https://") && !url.starts_with("http://") {
return url.to_string();
}

let Ok(mut parsed) = Url::parse(url) else {
return url.to_string();
};

if parsed.username().is_empty() {
return url.to_string();
}

let _ = parsed.set_username("***");
let _ = parsed.set_password(None);
parsed.into()
}

/// Extract a normalized hostname from a URL.
///
/// Handles standard HTTP(S) URLs and `git@` SSH URLs.
pub fn extract_url_host(url: &str) -> Option<String> {
if let Some(rest) = url.strip_prefix("git@") {
let host = rest.split(':').next().unwrap_or("").trim().to_lowercase();
if host.is_empty() {
return None;
}
return Some(host);
}

Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_lowercase()))
}

/// Look up a token for a URL from a credentials map.
pub fn get_token_for_url<'a>(
url: &str,
credentials: Option<&'a std::collections::HashMap<String, String>>,
) -> Option<&'a str> {
let host = extract_url_host(url)?;
let bare_host = host.split(':').next().unwrap_or(&host);

if let Some(creds) = credentials {
if let Some(token) = creds.get(&host).or_else(|| creds.get(bare_host)) {
return Some(token);
}
}

None
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_is_git_url_https() {
assert!(is_git_url("https://github.com/org/repo.git"));
}

#[test]
fn test_is_git_url_ssh() {
assert!(is_git_url("git@github.com:org/repo.git"));
}

#[test]
fn test_is_git_url_local_path() {
assert!(!is_git_url("/local/path/to/repo"));
}

#[test]
fn test_inject_token_github() {
let result = inject_token("https://github.com/org/repo", "mytoken");
assert_eq!(result, "https://mytoken@github.com/org/repo");
}

#[test]
fn test_inject_token_gitlab() {
let result = inject_token("https://gitlab.com/group/repo", "gltoken");
assert_eq!(result, "https://oauth2:gltoken@gitlab.com/group/repo");
}

#[test]
fn test_inject_token_non_github_self_hosted() {
let result = inject_token("https://git.example.com/repo", "tok");
assert_eq!(result, "https://oauth2:tok@git.example.com/repo");
}

#[test]
fn test_inject_token_ssh_unchanged() {
let url = "git@github.com:org/repo.git";
assert_eq!(inject_token(url, "mytoken"), url);
}

#[test]
fn test_inject_token_replaces_existing() {
let result = inject_token("https://oldtok@github.com/org/repo", "newtok");
assert_eq!(result, "https://newtok@github.com/org/repo");
}

#[test]
fn test_mask_token_in_url_masks() {
let result = mask_token_in_url("https://mytoken@github.com/org/repo");
assert_eq!(result, "https://***@github.com/org/repo");
assert!(!result.contains("mytoken"));
}

#[test]
fn test_mask_token_in_url_oauth2_format() {
let result = mask_token_in_url("https://oauth2:gltoken@gitlab.com/group/repo");
assert_eq!(result, "https://***@gitlab.com/group/repo");
assert!(!result.contains("gltoken"));
}

#[test]
fn test_mask_token_in_url_no_token_unchanged() {
let url = "https://github.com/org/repo";
assert_eq!(mask_token_in_url(url), url);
}

#[test]
fn test_extract_url_host_https() {
let result = extract_url_host("https://github.com/org/repo");
assert_eq!(result, Some("github.com".to_string()));
}

#[test]
fn test_extract_url_host_git_ssh() {
let result = extract_url_host("git@gitlab.com:group/project.git");
assert_eq!(result, Some("gitlab.com".to_string()));
}

#[test]
fn test_extract_url_host_with_token() {
let result = extract_url_host("https://mytoken@github.com/org/repo");
assert_eq!(result, Some("github.com".to_string()));
}

#[test]
fn test_get_token_for_url_exact_match() {
let mut creds = std::collections::HashMap::new();
creds.insert("github.com".to_string(), "mytoken".to_string());
let result = get_token_for_url("https://github.com/org/repo", Some(&creds));
assert_eq!(result, Some("mytoken"));
}

#[test]
fn test_get_token_for_url_no_match() {
let mut creds = std::collections::HashMap::new();
creds.insert("github.com".to_string(), "mytoken".to_string());
let result = get_token_for_url("https://gitlab.com/group/repo", Some(&creds));
assert_eq!(result, None);
}

#[test]
fn test_get_token_for_url_bare_host_match() {
let mut creds = std::collections::HashMap::new();
creds.insert("github.com".to_string(), "tok".to_string());
let result = get_token_for_url("https://github.com:443/org/repo", Some(&creds));
assert_eq!(result, Some("tok"));
}
}
96 changes: 96 additions & 0 deletions crates/ov_cli/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,24 @@ pub async fn handle_add_resource(
path = unescaped_path;
}

// Auto-inject git token from config if the path is a git URL and
// credentials are configured for the target host.
if is_url {
if let Some(ref creds) = ctx.config.git_credentials {
if let Some(host) = crate::git_credentials::extract_url_host(&path) {
let bare_host = host.split(':').next().unwrap_or(&host);
if let Some(token) = creds.get(&host).or_else(|| creds.get(bare_host)) {
let original = path.clone();
path = crate::git_credentials::inject_token(&path, token);
if ctx.is_verbose() {
let masked = crate::git_credentials::mask_token_in_url(&original);
eprintln!("Injected git token for {masked}");
}
}
}
}
}

// Check that only one of --to, --parent, or --parent-auto-create is set
let mut exclusive_count = 0;
if to.is_some() {
Expand Down Expand Up @@ -596,6 +614,7 @@ pub async fn handle_config(cmd: ConfigCommands, _ctx: CliContext) -> Result<()>
},
ConfigCommands::SetupCli => handle_setup_cli().await,
ConfigCommands::Switch => handle_config_switch().await,
ConfigCommands::GitCredentials => handle_configure_git_credentials().await,
}
}

Expand Down Expand Up @@ -698,6 +717,18 @@ async fn handle_setup_cli() -> Result<()> {
}
}

// Step 6: Optionally configure git credentials for private repos
println!();
println!(
"{}",
"Configure git credentials for private repositories? (optional)".blue()
);
let creds_prompt = "Add credentials now? [y/N]: ";
let creds_input = rl.readline(creds_prompt).unwrap_or_default();
if creds_input.trim().to_lowercase() == "y" || creds_input.trim().to_lowercase() == "yes" {
configure_git_credentials_interactive(&mut rl, &mut config)?;
}

println!();
println!("{}", "✓ Setup complete!".bold().green());
println!(
Expand All @@ -712,6 +743,71 @@ async fn handle_setup_cli() -> Result<()> {
Ok(())
}

/// Interactive prompt to collect git credentials and store them in the config.
fn configure_git_credentials_interactive(
rl: &mut rustyline::DefaultEditor,
config: &mut Config,
) -> Result<()> {
use colored::Colorize;

println!();
println!("{}", "Git Credentials Setup".bold());

let creds = config.git_credentials.get_or_insert_with(std::collections::HashMap::new);

loop {
println!();
let prompt = "Hostname (e.g. github.com, gitlab.com) [skip to finish]: ";
let host_input = rl.readline(prompt).unwrap_or_default();
if host_input.trim().is_empty() {
break;
}
let host = host_input.trim().to_string();

let token_prompt = format!("Personal Access Token for {host}: ");
let token_input = rl.readline(token_prompt.as_str()).unwrap_or_default();
if token_input.trim().is_empty() {
println!(" Token is required — skipping this entry.");
continue;
}
let token = token_input.trim().to_string();

creds.insert(host.clone(), token);
println!(" {} Token saved for {host}.", "✓".green());
}

// Persist updated config to disk.
let _ = std::fs::create_dir_all(
crate::config::default_config_path()?.parent().unwrap(),
);
config.save_default()?;
println!();
println!("{}", "Git credentials saved.".green());

Ok(())
}

/// Standalone command: ov config git-credentials
async fn handle_configure_git_credentials() -> Result<()> {
use colored::Colorize;
use rustyline::DefaultEditor;

println!("{}", "Git Credentials Setup".bold().green());
println!();

let mut config = match Config::load_default() {
Ok(c) => c,
Err(_) => Config::default(),
};

let mut rl = DefaultEditor::new()
.map_err(|e| Error::Config(format!("Failed to initialize input editor: {}", e)))?;

configure_git_credentials_interactive(&mut rl, &mut config)?;

Ok(())
}

/// Probe health endpoint to check server status and auth requirement
async fn probe_health(base_url: &str, timeout_secs: f64) -> Result<(bool, bool)> {
let client = BaseClient::new_simple(base_url, timeout_secs);
Expand Down
5 changes: 5 additions & 0 deletions crates/ov_cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod client;
mod commands;
mod config;
mod error;
mod git_credentials;
mod handlers;
mod output;
mod tui;
Expand Down Expand Up @@ -1029,6 +1030,8 @@ enum ConfigCommands {
SetupCli,
/// Switch between saved configurations
Switch,
/// Configure git credentials for private repository access
GitCredentials,
}

fn find_command_index(args: &[OsString]) -> Option<usize> {
Expand Down Expand Up @@ -1671,6 +1674,7 @@ mod tests {
verbose: false,
upload: Default::default(),
extra_headers: None,
git_credentials: None,
profile: false,
};

Expand Down Expand Up @@ -1709,6 +1713,7 @@ mod tests {
profile: false,
upload: Default::default(),
extra_headers: None,
git_credentials: None,
};

// Without sudo: use api_key
Expand Down
Loading