diff --git a/README.md b/README.md index d6f414bb3..c61e0acc3 100644 --- a/README.md +++ b/README.md @@ -537,7 +537,6 @@ The following Git top-level commands are currently **not implemented** in Libra - `maintenance` – periodic maintenance tasks - `cat-file` – display raw object contents - `hash-object` – compute object hash for raw data -- `rev-parse` – resolve revisions, refs, and object IDs - `rev-list` – list reachable commits - `describe` – human-readable description based on tags - `show-ref` – list all refs diff --git a/src/cli.rs b/src/cli.rs index ee887d005..d62f1b53f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -200,6 +200,8 @@ enum Commands { Merge(command::merge::MergeArgs), #[command(about = "Reset current HEAD to specified state")] Reset(command::reset::ResetArgs), + #[command(about = "Parse and normalize revision names and repository paths")] + RevParse(command::rev_parse::RevParseArgs), #[command(about = "Move or rename a file, a directory, or a symlink")] Mv(command::mv::MvArgs), #[command( @@ -651,6 +653,7 @@ pub async fn parse_async(args: Option<&[&str]>) -> CliResult<()> { Commands::Rebase(cmd_args) => command::rebase::execute_safe(cmd_args, &output).await?, Commands::Merge(cmd_args) => command::merge::execute_safe(cmd_args, &output).await?, Commands::Reset(cmd_args) => command::reset::execute_safe(cmd_args, &output).await?, + Commands::RevParse(cmd_args) => command::rev_parse::execute_safe(cmd_args, &output).await?, Commands::Mv(cmd_args) => command::mv::execute_safe(cmd_args, &output).await?, Commands::Describe(cmd_args) => command::describe::execute_safe(cmd_args, &output).await?, Commands::CherryPick(cmd_args) => { diff --git a/src/command/cloud.rs b/src/command/cloud.rs index 3222c1d20..d81ccfff6 100644 --- a/src/command/cloud.rs +++ b/src/command/cloud.rs @@ -8,6 +8,7 @@ use std::{ collections::hash_map::DefaultHasher, hash::{Hash, Hasher}, + path::PathBuf, sync::Arc, }; @@ -591,14 +592,31 @@ async fn execute_status(args: StatusArgs) -> Result<(), String> { Ok(()) } -async fn resolve_cloud_env(name: &str) -> Result, String> { - crate::internal::config::resolve_env(name) +fn cloud_local_db_path() -> Result { + let storage = util::try_get_storage_path(None) + .map_err(|e| format!("failed to resolve current repository storage: {e}"))?; + Ok(storage.join(util::DATABASE)) +} + +async fn resolve_cloud_env( + name: &str, + local_db_path: Option<&std::path::Path>, +) -> Result, String> { + let local_target = match local_db_path { + Some(db_path) => crate::internal::config::LocalIdentityTarget::ExplicitDb(db_path), + None => crate::internal::config::LocalIdentityTarget::CurrentRepo, + }; + + crate::internal::config::resolve_env_for_target(name, local_target) .await .map_err(|e| format!("failed to resolve '{name}' from env or config: {e}")) } -async fn resolve_required_cloud_env(name: &str) -> Result { - match resolve_cloud_env(name).await? { +async fn resolve_required_cloud_env( + name: &str, + local_db_path: Option<&std::path::Path>, +) -> Result { + match resolve_cloud_env(name, local_db_path).await? { Some(value) if !value.is_empty() => Ok(value), _ => Err(format!("{name} not set")), } @@ -606,11 +624,22 @@ async fn resolve_required_cloud_env(name: &str) -> Result { /// Create R2 remote storage from environment variables and config. async fn create_r2_storage(repo_id: &str) -> Result { - let endpoint = resolve_required_cloud_env("LIBRA_STORAGE_ENDPOINT").await?; - let bucket = resolve_required_cloud_env("LIBRA_STORAGE_BUCKET").await?; - let access_key = resolve_required_cloud_env("LIBRA_STORAGE_ACCESS_KEY").await?; - let secret_key = resolve_required_cloud_env("LIBRA_STORAGE_SECRET_KEY").await?; - let region = resolve_cloud_env("LIBRA_STORAGE_REGION") + let local_db_path = cloud_local_db_path()?; + create_r2_storage_for_db_path(repo_id, &local_db_path).await +} + +async fn create_r2_storage_for_db_path( + repo_id: &str, + local_db_path: &std::path::Path, +) -> Result { + let endpoint = + resolve_required_cloud_env("LIBRA_STORAGE_ENDPOINT", Some(local_db_path)).await?; + let bucket = resolve_required_cloud_env("LIBRA_STORAGE_BUCKET", Some(local_db_path)).await?; + let access_key = + resolve_required_cloud_env("LIBRA_STORAGE_ACCESS_KEY", Some(local_db_path)).await?; + let secret_key = + resolve_required_cloud_env("LIBRA_STORAGE_SECRET_KEY", Some(local_db_path)).await?; + let region = resolve_cloud_env("LIBRA_STORAGE_REGION", Some(local_db_path)) .await? .filter(|value| !value.is_empty()) .unwrap_or_else(|| "auto".to_string()); @@ -647,9 +676,10 @@ async fn validate_cloud_backup_env(skip_r2: bool) -> Result<(), String> { ]); } + let local_db_path = cloud_local_db_path()?; let mut missing = Vec::new(); for key in required { - match resolve_cloud_env(key).await? { + match resolve_cloud_env(key, Some(&local_db_path)).await? { Some(value) if !value.is_empty() => {} _ => missing.push(key), } @@ -910,8 +940,14 @@ mod tests { .unwrap(); }); - rt.block_on(create_r2_storage("repo-from-config")) - .expect("R2 storage should initialize from local config values"); + let repo_db_path = repo.path().join(".libra").join(util::DATABASE); + let _manifest_dir = ChangeDirGuard::new(env!("CARGO_MANIFEST_DIR")); + + rt.block_on(create_r2_storage_for_db_path( + "repo-from-config", + &repo_db_path, + )) + .expect("R2 storage should initialize from local config values even after cwd drift"); } #[test] @@ -934,7 +970,8 @@ mod tests { .block_on(validate_cloud_backup_env(true)) .expect_err("global config resolution failure should surface"); assert!( - err.contains("failed to connect to global config"), + err.contains("failed to open config database") + || err.contains("failed to connect to global config"), "unexpected error: {err}" ); } diff --git a/src/command/fetch.rs b/src/command/fetch.rs index 51e5e591d..f377f6db9 100644 --- a/src/command/fetch.rs +++ b/src/command/fetch.rs @@ -278,8 +278,20 @@ fn load_vault_unseal_key_sync() -> Result>, String> { } } +fn resolve_home_directory() -> Result { + for key in ["HOME", "USERPROFILE"] { + if let Some(value) = std::env::var_os(key) + && !value.is_empty() + { + return Ok(PathBuf::from(value)); + } + } + + dirs::home_dir().ok_or_else(|| "cannot determine home directory".to_string()) +} + fn ensure_vault_ssh_tmp_dir() -> Result { - let home = dirs::home_dir().ok_or_else(|| "cannot determine home directory".to_string())?; + let home = resolve_home_directory()?; let tmp_dir = home.join(".libra").join("tmp"); std::fs::create_dir_all(&tmp_dir).map_err(|e| { format!( diff --git a/src/command/mod.rs b/src/command/mod.rs index 4eb9e4506..316065f47 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -32,6 +32,7 @@ pub mod remote; pub mod remove; pub mod reset; pub mod restore; +pub mod rev_parse; pub mod revert; pub mod shortlog; pub mod show; diff --git a/src/command/rev_parse.rs b/src/command/rev_parse.rs new file mode 100644 index 000000000..a882dab9a --- /dev/null +++ b/src/command/rev_parse.rs @@ -0,0 +1,242 @@ +//! Implements `rev-parse` to resolve revision names and print basic repository paths. + +use std::io::Write; + +use clap::Parser; +use git_internal::hash::ObjectHash; +use serde::Serialize; + +use crate::{ + internal::{ + branch::{Branch, BranchStoreError}, + head::Head, + }, + utils::{ + error::{CliError, CliResult, StableErrorCode}, + output::{OutputConfig, emit_json_data}, + text::SHORT_HASH_LEN, + util::{self, CommitBaseError}, + }, +}; + +#[derive(Parser, Debug)] +pub struct RevParseArgs { + /// Show a non-ambiguous short object name. + #[clap(long)] + pub short: bool, + + /// Show the branch name instead of the commit hash. + #[clap(long = "abbrev-ref", conflicts_with = "show_toplevel")] + pub abbrev_ref: bool, + + /// Show the absolute path of the top-level working tree. + #[clap(long = "show-toplevel", conflicts_with = "abbrev_ref")] + pub show_toplevel: bool, + + /// Revision to parse. Defaults to HEAD when omitted. + #[clap(value_name = "SPEC")] + pub spec: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct RevParseOutput { + mode: &'static str, + input: Option, + value: String, +} + +pub async fn execute(args: RevParseArgs) -> Result<(), String> { + execute_safe(args, &OutputConfig::default()) + .await + .map_err(|err| err.render()) +} + +pub async fn execute_safe(args: RevParseArgs, output: &OutputConfig) -> CliResult<()> { + let result = resolve_rev_parse(&args).await?; + + if output.is_json() { + emit_json_data("rev-parse", &result, output) + } else if output.quiet { + Ok(()) + } else { + let stdout = std::io::stdout(); + let mut writer = stdout.lock(); + writeln!(writer, "{}", result.value) + .map_err(|e| CliError::io(format!("failed to write rev-parse output: {e}"))) + } +} + +async fn resolve_rev_parse(args: &RevParseArgs) -> CliResult { + if args.show_toplevel { + let workdir = util::try_working_dir().map_err(map_repo_path_error)?; + return Ok(RevParseOutput { + mode: "show_toplevel", + input: None, + value: util::path_to_string(&workdir), + }); + } + + let spec = args.spec.as_deref().unwrap_or("HEAD"); + + if args.abbrev_ref { + let value = resolve_abbrev_ref(spec).await?; + return Ok(RevParseOutput { + mode: "abbrev_ref", + input: Some(spec.to_string()), + value, + }); + } + + let commit = util::get_commit_base_typed(spec) + .await + .map_err(|err| rev_parse_target_error(spec, err))?; + let value = if args.short { + resolve_short_commit(&commit).await? + } else { + commit.to_string() + }; + + Ok(RevParseOutput { + mode: if args.short { "short" } else { "resolve" }, + input: Some(spec.to_string()), + value, + }) +} + +async fn resolve_abbrev_ref(spec: &str) -> CliResult { + if spec.eq_ignore_ascii_case("HEAD") { + return match Head::current_result().await { + Ok(Head::Branch(name)) => Ok(name), + Ok(Head::Detached(_)) => Ok("HEAD".to_string()), + Err(error) => Err(map_head_resolution_error(error)), + }; + } + + if let Some(branch) = Branch::find_branch_result(spec, None) + .await + .map_err(|error| map_symbolic_ref_resolution_error(spec, error))? + { + return Ok(branch.name); + } + + if let Some((remote, branch_name)) = spec.split_once('/') + && !remote.is_empty() + && !branch_name.is_empty() + && Branch::find_branch_result(branch_name, Some(remote)) + .await + .map_err(|error| map_symbolic_ref_resolution_error(spec, error))? + .is_some() + { + return Ok(spec.to_string()); + } + + Err(CliError::failure(format!("not a symbolic ref: '{spec}'")) + .with_stable_code(StableErrorCode::CliInvalidTarget) + .with_hint("use 'libra rev-parse ' to resolve it to a commit hash.")) +} + +async fn resolve_short_commit(commit: &ObjectHash) -> CliResult { + let full = commit.to_string(); + let storage = util::objects_storage(); + + for len in SHORT_HASH_LEN..=full.len() { + let prefix = &full[..len]; + let matches = storage.search_result(prefix).await.map_err(|error| { + CliError::fatal(format!( + "failed to search objects while abbreviating '{full}': {error}" + )) + .with_stable_code(StableErrorCode::IoReadFailed) + })?; + + if matches.len() == 1 && matches[0] == *commit { + return Ok(prefix.to_string()); + } + } + + Ok(full) +} + +fn map_repo_path_error(err: std::io::Error) -> CliError { + match err.kind() { + std::io::ErrorKind::NotFound => CliError::repo_not_found(), + _ => CliError::io(format!("failed to determine repository root: {err}")) + .with_stable_code(StableErrorCode::IoReadFailed), + } +} + +fn map_head_resolution_error(error: BranchStoreError) -> CliError { + map_symbolic_ref_resolution_error("HEAD", error) +} + +fn map_symbolic_ref_resolution_error(spec: &str, error: BranchStoreError) -> CliError { + match error { + BranchStoreError::Corrupt { detail, .. } => { + CliError::fatal(format!("failed to resolve symbolic ref '{spec}': {detail}")) + .with_stable_code(StableErrorCode::RepoCorrupt) + } + BranchStoreError::Query(detail) + | BranchStoreError::NotFound(detail) + | BranchStoreError::Delete { detail, .. } => { + CliError::fatal(format!("failed to resolve symbolic ref '{spec}': {detail}")) + .with_stable_code(StableErrorCode::IoReadFailed) + } + } +} + +fn rev_parse_target_error(spec: &str, error: CommitBaseError) -> CliError { + match error { + CommitBaseError::HeadUnborn => CliError::failure(format!( + "not a valid object name: '{spec}' (HEAD does not point to a commit)" + )) + .with_stable_code(StableErrorCode::CliInvalidTarget) + .with_hint("create a commit before resolving HEAD."), + CommitBaseError::InvalidReference(detail) => { + CliError::failure(format!("not a valid object name: '{spec}' ({detail})")) + .with_stable_code(StableErrorCode::CliInvalidTarget) + } + CommitBaseError::ReadFailure(detail) => { + CliError::fatal(format!("failed to resolve '{spec}': {detail}")) + .with_stable_code(StableErrorCode::IoReadFailed) + } + CommitBaseError::CorruptReference(detail) => { + CliError::fatal(format!("failed to resolve '{spec}': {detail}")) + .with_stable_code(StableErrorCode::RepoCorrupt) + } + } +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::RevParseArgs; + + #[test] + fn test_rev_parse_args_default() { + let args = RevParseArgs::try_parse_from(["rev-parse"]).unwrap(); + assert!(!args.short); + assert!(!args.abbrev_ref); + assert!(!args.show_toplevel); + assert!(args.spec.is_none()); + } + + #[test] + fn test_rev_parse_args_short_head() { + let args = RevParseArgs::try_parse_from(["rev-parse", "--short", "HEAD"]).unwrap(); + assert!(args.short); + assert_eq!(args.spec.as_deref(), Some("HEAD")); + } + + #[test] + fn test_rev_parse_args_abbrev_ref() { + let args = RevParseArgs::try_parse_from(["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); + assert!(args.abbrev_ref); + assert_eq!(args.spec.as_deref(), Some("HEAD")); + } + + #[test] + fn test_rev_parse_args_show_toplevel() { + let args = RevParseArgs::try_parse_from(["rev-parse", "--show-toplevel"]).unwrap(); + assert!(args.show_toplevel); + } +} diff --git a/src/command/tag.rs b/src/command/tag.rs index b209e67ba..33bcebb25 100644 --- a/src/command/tag.rs +++ b/src/command/tag.rs @@ -491,47 +491,50 @@ mod tests { use super::*; use crate::{ - cli::parse_async, - command::init::{self, InitArgs}, - internal::tag, - utils::test::ChangeDirGuard, + command::{ + add::{self, AddArgs}, + commit::{self, CommitArgs}, + }, + internal::{config::ConfigKv, tag}, + utils::test::{ChangeDirGuard, setup_with_new_libra_in}, }; async fn setup_repo_with_commit() -> (tempfile::TempDir, ChangeDirGuard) { let temp_dir = tempdir().unwrap(); + setup_with_new_libra_in(temp_dir.path()).await; let guard = ChangeDirGuard::new(temp_dir.path()); - init::init(InitArgs { - bare: false, - template: None, - initial_branch: None, - repo_directory: ".".to_string(), - quiet: false, - shared: None, - object_format: None, - ref_format: None, - from_git_repository: None, - vault: false, - }) - .await - .unwrap(); - parse_async(Some(&["libra", "config", "user.name", "Tag Test User"])) - .await - .unwrap(); - parse_async(Some(&[ - "libra", - "config", - "user.email", - "tag-test@example.com", - ])) - .await - .unwrap(); - fs::write("test.txt", "hello").unwrap(); - parse_async(Some(&["libra", "add", "test.txt"])) + ConfigKv::set("user.name", "Tag Test User", false) .await .unwrap(); - parse_async(Some(&["libra", "commit", "-m", "Initial commit"])) + ConfigKv::set("user.email", "tag-test@example.com", false) .await .unwrap(); + fs::write("test.txt", "hello").unwrap(); + add::execute(AddArgs { + pathspec: vec!["test.txt".to_string()], + all: false, + update: false, + verbose: false, + dry_run: false, + ignore_errors: false, + refresh: false, + force: false, + }) + .await; + commit::execute(CommitArgs { + message: Some("Initial commit".to_string()), + file: None, + allow_empty: false, + conventional: false, + no_edit: false, + amend: false, + signoff: false, + disable_pre: false, + all: false, + no_verify: true, + author: None, + }) + .await; (temp_dir, guard) } diff --git a/src/internal/ai/claudecode/common.rs b/src/internal/ai/claudecode/common.rs index 01e6a7c52..809c4b96b 100644 --- a/src/internal/ai/claudecode/common.rs +++ b/src/internal/ai/claudecode/common.rs @@ -46,10 +46,6 @@ pub(super) async fn init_local_mcp_server(storage_dir: &Path) -> Result, working_dir: &Path) -> String { } fn relative_or_display(path: PathBuf, working_dir: &Path) -> String { - path.strip_prefix(working_dir) + let rendered = path + .strip_prefix(working_dir) .map(|rel| rel.to_string_lossy().to_string()) - .unwrap_or_else(|_| path.to_string_lossy().to_string()) + .unwrap_or_else(|_| path.to_string_lossy().to_string()); + rendered.replace('\\', "/") } fn shell_looks_networked(arguments: &Value) -> bool { diff --git a/src/internal/ai/orchestrator/workspace.rs b/src/internal/ai/orchestrator/workspace.rs index 1d577611b..fe1842880 100644 --- a/src/internal/ai/orchestrator/workspace.rs +++ b/src/internal/ai/orchestrator/workspace.rs @@ -50,7 +50,9 @@ struct FuseTaskWorktreeBackend { struct TaskWorktreePaths { cleanup_root: PathBuf, workspace_root: PathBuf, + #[cfg(unix)] lower_root: PathBuf, + #[cfg(unix)] upper_root: PathBuf, } @@ -93,7 +95,9 @@ fn task_worktree_paths(task_id: Uuid, backend: &str) -> TaskWorktreePaths { )); TaskWorktreePaths { workspace_root: cleanup_root.join("workspace"), + #[cfg(unix)] lower_root: cleanup_root.join("lower"), + #[cfg(unix)] upper_root: cleanup_root.join("upper"), cleanup_root, } @@ -113,7 +117,7 @@ fn prepare_copy_task_worktree( ) -> io::Result { fs::create_dir_all(&paths.workspace_root)?; match util::try_get_storage_path(Some(main_working_dir.to_path_buf())) { - Ok(storage) => link_repo_storage( + Ok(storage) => populate_repo_storage( &storage, &paths.workspace_root.join(util::ROOT_DIR), "copy task worktree", @@ -145,7 +149,7 @@ fn prepare_fuse_task_worktree( match util::try_get_storage_path(Some(main_working_dir.to_path_buf())) { Ok(storage) => { - link_repo_storage( + populate_repo_storage( &storage, &paths.upper_root.join(util::ROOT_DIR), "FUSE upper layer", @@ -495,19 +499,69 @@ fn remove_existing_target(path: &Path) -> io::Result<()> { } } -fn link_repo_storage(storage: &Path, link_path: &Path, context: &str) -> io::Result<()> { - create_storage_link(storage, link_path).map_err(|err| { - io::Error::new( +#[cfg(windows)] +fn is_windows_symlink_privilege_error(err: &io::Error) -> bool { + err.kind() == io::ErrorKind::PermissionDenied || err.raw_os_error() == Some(1314) +} + +fn populate_repo_storage(storage: &Path, target_path: &Path, context: &str) -> io::Result<()> { + populate_repo_storage_with_link(storage, target_path, context, create_storage_link) +} + +fn populate_repo_storage_with_link( + storage: &Path, + target_path: &Path, + context: &str, + link: impl FnOnce(&Path, &Path) -> io::Result<()>, +) -> io::Result<()> { + match link(storage, target_path) { + Ok(()) => Ok(()), + #[cfg(windows)] + Err(err) if is_windows_symlink_privilege_error(&err) => { + copy_dir_all(storage, target_path).map_err(|copy_err| { + io::Error::new( + copy_err.kind(), + format!( + "failed to populate repository storage '{}' into {} at '{}' after symlink fallback: {}", + storage.display(), + context, + target_path.display(), + copy_err + ), + ) + }) + } + Err(err) => Err(io::Error::new( err.kind(), format!( "failed to link repository storage '{}' into {} at '{}': {}", storage.display(), context, - link_path.display(), + target_path.display(), err ), - ) - }) + )), + } +} + +#[cfg(any(windows, test))] +fn copy_dir_all(source: &Path, target: &Path) -> io::Result<()> { + fs::create_dir_all(target)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let file_type = entry.file_type()?; + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_all(&source_path, &target_path)?; + } else if file_type.is_symlink() { + let link_target = fs::read_link(&source_path)?; + create_symlink(&link_target, &source_path, &target_path)?; + } else { + clone_or_copy_file(&source_path, &target_path)?; + } + } + Ok(()) } fn remove_empty_parents(root: &Path, mut current: Option<&Path>) { @@ -562,8 +616,10 @@ mod tests { use tempfile::tempdir; use uuid::Uuid; + #[cfg(windows)] + use super::populate_repo_storage_with_link; use super::{ - cleanup_task_worktree, clone_or_copy_file, materialize_workspace, prepare_task_worktree, + cleanup_task_worktree, copy_dir_all, materialize_workspace, prepare_task_worktree, sync_task_worktree_back, }; use crate::{ @@ -584,16 +640,54 @@ mod tests { } } + #[cfg(windows)] + fn is_windows_symlink_privilege_error(err: &io::Error) -> bool { + err.kind() == io::ErrorKind::PermissionDenied || err.raw_os_error() == Some(1314) + } + #[test] - fn clone_or_copy_file_preserves_contents() { + fn copy_dir_all_copies_nested_storage_tree() { let temp = tempdir().unwrap(); - let source = temp.path().join("source.txt"); - let target = temp.path().join("target.txt"); - std::fs::write(&source, "cow me maybe\n").unwrap(); + let source = temp.path().join("source"); + let target = temp.path().join("target"); + std::fs::create_dir_all(source.join("objects/ab")).unwrap(); + std::fs::write(source.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + std::fs::write(source.join("objects/ab/cd"), "blob\n").unwrap(); + + copy_dir_all(&source, &target).unwrap(); + + assert_eq!( + std::fs::read_to_string(target.join("HEAD")).unwrap(), + "ref: refs/heads/main\n" + ); + assert_eq!( + std::fs::read_to_string(target.join("objects/ab/cd")).unwrap(), + "blob\n" + ); + } - clone_or_copy_file(&source, &target).unwrap(); + #[test] + #[cfg(windows)] + fn populate_repo_storage_falls_back_to_copy_on_windows_symlink_privilege_error() { + let temp = tempdir().unwrap(); + let source = temp.path().join("storage"); + let target = temp.path().join("workspace/.libra"); + std::fs::create_dir_all(source.join("objects/ab")).unwrap(); + std::fs::write(source.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + std::fs::write(source.join("objects/ab/cd"), "blob\n").unwrap(); + + let err = io::Error::from_raw_os_error(1314); + populate_repo_storage_with_link(&source, &target, "copy task worktree", |_, _| Err(err)) + .unwrap(); - assert_eq!(std::fs::read_to_string(&target).unwrap(), "cow me maybe\n"); + assert_eq!( + std::fs::read_to_string(target.join("HEAD")).unwrap(), + "ref: refs/heads/main\n" + ); + assert_eq!( + std::fs::read_to_string(target.join("objects/ab/cd")).unwrap(), + "blob\n" + ); } #[test] @@ -604,7 +698,14 @@ mod tests { std::fs::create_dir_all(root.join("nested")).unwrap(); std::fs::create_dir_all(&external).unwrap(); std::fs::write(external.join("secret.txt"), "outside\n").unwrap(); - symlink_path(&external, &root.join("nested").join("external-link")).unwrap(); + if let Err(err) = symlink_path(&external, &root.join("nested").join("external-link")) { + #[cfg(windows)] + if is_windows_symlink_privilege_error(&err) { + eprintln!("skipping directory symlink test on Windows without symlink privilege"); + return; + } + panic!("failed to create directory symlink fixture: {err}"); + } let snapshot = snapshot_workspace(&root).unwrap(); @@ -628,7 +729,16 @@ mod tests { let task = temp.path().join("task"); std::fs::create_dir_all(&main).unwrap(); std::fs::write(main.join("target.txt"), "base\n").unwrap(); - symlink_path(std::path::Path::new("target.txt"), &main.join("link.txt")).unwrap(); + if let Err(err) = symlink_path(std::path::Path::new("target.txt"), &main.join("link.txt")) { + #[cfg(windows)] + if is_windows_symlink_privilege_error(&err) { + eprintln!( + "skipping symlink preservation test on Windows without symlink privilege" + ); + return; + } + panic!("failed to create source symlink fixture: {err}"); + } let baseline = snapshot_workspace(&main).unwrap(); std::fs::create_dir_all(&task).unwrap(); @@ -641,7 +751,17 @@ mod tests { ); std::fs::remove_file(task.join("link.txt")).unwrap(); - symlink_path(std::path::Path::new("updated.txt"), &task.join("link.txt")).unwrap(); + if let Err(err) = symlink_path(std::path::Path::new("updated.txt"), &task.join("link.txt")) + { + #[cfg(windows)] + if is_windows_symlink_privilege_error(&err) { + eprintln!( + "skipping symlink preservation test on Windows without symlink privilege" + ); + return; + } + panic!("failed to update task symlink fixture: {err}"); + } sync_task_worktree_back(&main, &task, &baseline, &[], &[], &[]).unwrap(); @@ -706,10 +826,7 @@ mod tests { let err = sync_task_worktree_back(&main, &task, &baseline, &[], &["src/".to_string()], &[]) .unwrap_err(); - assert!( - err.to_string() - .contains("path 'docs/readme.md' not in any in-scope pattern") - ); + assert!(err.to_string().contains("outside its declared contract")); assert_eq!( std::fs::read_to_string(main.join("docs/readme.md")).unwrap(), "base\n" diff --git a/src/internal/ai/tools/utils.rs b/src/internal/ai/tools/utils.rs index 57b40624d..74a9f1c1a 100644 --- a/src/internal/ai/tools/utils.rs +++ b/src/internal/ai/tools/utils.rs @@ -129,14 +129,18 @@ mod tests { #[test] fn test_validate_path_absolute() { - let working_dir = PathBuf::from("/tmp/work"); - let path = PathBuf::from("/tmp/work/file.txt"); + let temp = tempdir().unwrap(); + let working_dir = temp.path().join("work"); + fs::create_dir_all(&working_dir).unwrap(); + let path = working_dir.join("file.txt"); assert!(validate_path(&path, &working_dir).is_ok()); } #[test] fn test_validate_path_relative() { - let working_dir = PathBuf::from("/tmp/work"); + let temp = tempdir().unwrap(); + let working_dir = temp.path().join("work"); + fs::create_dir_all(&working_dir).unwrap(); let path = PathBuf::from("relative/file.txt"); assert!(matches!( validate_path(&path, &working_dir), @@ -146,20 +150,24 @@ mod tests { #[test] fn test_validate_path_outside_working_dir() { - let working_dir = PathBuf::from("/tmp/work"); - let path = PathBuf::from("/etc/passwd"); - // The result depends on whether the path is a subpath of working_dir - // Since /etc is not under /tmp/work, this should fail + let temp = tempdir().unwrap(); + let working_dir = temp.path().join("work"); + let outside_root = temp.path().join("outside"); + fs::create_dir_all(&working_dir).unwrap(); + fs::create_dir_all(&outside_root).unwrap(); + let path = outside_root.join("passwd"); let result = validate_path(&path, &working_dir); assert!(result.is_err()); } #[test] fn test_resolve_path_relative_to_working_dir() { - let working_dir = PathBuf::from("/tmp/work"); + let temp = tempdir().unwrap(); + let working_dir = temp.path().join("work"); + fs::create_dir_all(working_dir.join("src")).unwrap(); let path = PathBuf::from("src/main.rs"); let resolved = resolve_path(&path, &working_dir).unwrap(); - assert_eq!(resolved, PathBuf::from("/tmp/work/src/main.rs")); + assert_eq!(resolved, working_dir.join("src/main.rs")); } #[test] diff --git a/src/internal/ai/workspace_snapshot.rs b/src/internal/ai/workspace_snapshot.rs index 5eba8e23b..b644b85ed 100644 --- a/src/internal/ai/workspace_snapshot.rs +++ b/src/internal/ai/workspace_snapshot.rs @@ -177,7 +177,16 @@ mod tests { fs::write(root.join(".codex/session"), "state\n").unwrap(); fs::write(root.join(".agents/cache"), "cache\n").unwrap(); fs::write(root.join("real.txt"), "hello\n").unwrap(); - symlink_path(Path::new("real.txt"), &root.join("nested/link.txt")).unwrap(); + if let Err(err) = symlink_path(Path::new("real.txt"), &root.join("nested/link.txt")) { + #[cfg(windows)] + if matches!(err.kind(), io::ErrorKind::PermissionDenied) + || err.raw_os_error() == Some(1314) + { + eprintln!("skipping symlink assertion on Windows without symlink privilege"); + return; + } + panic!("failed to create symlink fixture: {err}"); + } let snapshot = snapshot_workspace(&root).unwrap(); diff --git a/src/internal/config.rs b/src/internal/config.rs index 30b451941..cf24647d5 100644 --- a/src/internal/config.rs +++ b/src/internal/config.rs @@ -670,63 +670,33 @@ pub async fn encrypt_value(value: &str, scope: &str) -> Result { /// `name` is the raw env var name (e.g. `"GEMINI_API_KEY"`). /// Returns `Err` if a vault/DB query fails (not the same as "not configured"). pub async fn resolve_env(name: &str) -> Result> { + resolve_env_for_target(name, LocalIdentityTarget::CurrentRepo).await +} + +/// Resolve an environment variable using an explicit local config target. +/// +/// Resolution order remains: +/// 1. System environment variable (`std::env::var`) +/// 2. Local config for `local_target` (`vault.env.`) +/// 3. Global config (`vault.env.` in `~/.libra/config.db`) +pub async fn resolve_env_for_target( + name: &str, + local_target: LocalIdentityTarget<'_>, +) -> Result> { // 1. System environment variable — per-process override (12-Factor) if let Ok(val) = std::env::var(name) { return Ok(Some(val)); } - // 2. Local config (vault.env.*) let vault_key = format!("vault.env.{name}"); - match ConfigKv::get(&vault_key).await { - Ok(Some(entry)) => { - if entry.encrypted { - // Decrypt the stored value using local-scope unseal key - let plaintext = decrypt_value(&entry.value, "local") - .await - .context(format!("failed to decrypt vault.env.{name}"))?; - return Ok(Some(plaintext)); - } - return Ok(Some(entry.value)); - } - Ok(None) => {} - Err(e) => { - return Err(e.context(format!("failed to read '{name}' from local config"))); - } - } - // 3. Global config — lowest priority - if let Some(global_path) = global_config_path() - && global_path.exists() - { - let conn = crate::internal::db::establish_connection(&global_path.to_string_lossy()) - .await - .with_context(|| { - format!( - "failed to connect to global config '{}'", - global_path.display() - ) - })?; - match ConfigKv::get_with_conn(&conn, &vault_key).await { - Ok(Some(entry)) => { - if entry.encrypted { - let plaintext = - decrypt_value(&entry.value, "global") - .await - .context(format!( - "failed to decrypt vault.env.{name} from global config" - ))?; - return Ok(Some(plaintext)); - } - return Ok(Some(entry.value)); - } - Ok(None) => {} - Err(e) => { - return Err(e.context(format!("failed to read '{name}' from global config"))); - } - } + // 2. Local config (vault.env.*) + if let Some(value) = local_env_value_for_target(local_target, &vault_key).await? { + return Ok(Some(value)); } - Ok(None) + // 3. Global config — lowest priority + global_env_value(name, &vault_key).await } /// Resolve the global config database path. @@ -807,6 +777,66 @@ pub async fn resolve_user_identity_sources( }) } +async fn local_env_value_for_target( + local_target: LocalIdentityTarget<'_>, + vault_key: &str, +) -> Result> { + let Some(entry) = local_config_entry_for_target(local_target, vault_key).await? else { + return Ok(None); + }; + + if entry.encrypted { + let plaintext = decrypt_value(&entry.value, "local") + .await + .context(format!("failed to decrypt {vault_key}"))?; + return Ok(Some(plaintext)); + } + + Ok(Some(entry.value)) +} + +async fn local_config_entry_for_target( + local_target: LocalIdentityTarget<'_>, + key: &str, +) -> Result> { + match local_target { + LocalIdentityTarget::CurrentRepo => { + let storage = crate::utils::util::try_get_storage_path(None) + .context("failed to resolve current repository storage")?; + let db_path = storage.join(crate::utils::util::DATABASE); + read_config_entry_from_db_path(&db_path, key).await + } + LocalIdentityTarget::ExplicitDb(db_path) => { + read_config_entry_from_db_path(db_path, key).await + } + LocalIdentityTarget::None => Ok(None), + } +} + +async fn global_env_value(name: &str, vault_key: &str) -> Result> { + let Some(global_path) = global_config_path() else { + return Ok(None); + }; + if !global_path.exists() { + return Ok(None); + } + + let Some(entry) = read_config_entry_from_db_path(&global_path, vault_key).await? else { + return Ok(None); + }; + + if entry.encrypted { + let plaintext = decrypt_value(&entry.value, "global") + .await + .context(format!( + "failed to decrypt vault.env.{name} from global config" + ))?; + return Ok(Some(plaintext)); + } + + Ok(Some(entry.value)) +} + async fn local_config_value_for_target( local_target: LocalIdentityTarget<'_>, key: &str, @@ -836,6 +866,17 @@ async fn global_config_value(key: &str) -> Result> { } async fn read_config_value_from_db_path(db_path: &Path, key: &str) -> Result> { + let entry = read_config_entry_from_db_path(db_path, key).await?; + Ok(entry.and_then(|entry| { + let trimmed = entry.value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + })) +} + +async fn read_config_entry_from_db_path( + db_path: &Path, + key: &str, +) -> Result> { if !db_path.exists() { return Ok(None); } @@ -843,17 +884,12 @@ async fn read_config_value_from_db_path(db_path: &Path, key: &str) -> Result Output { .expect("failed to execute libra binary") } +#[allow(dead_code)] fn run_libra_command_with_stdin(args: &[&str], cwd: &Path, stdin_body: &str) -> Output { let mut child = base_libra_command(args, cwd) .stdin(Stdio::piped()) @@ -101,6 +102,7 @@ fn run_libra_command_with_stdin(args: &[&str], cwd: &Path, stdin_body: &str) -> .expect("failed to collect libra command output") } +#[allow(dead_code)] fn run_libra_command_with_stdin_and_env( args: &[&str], cwd: &Path, @@ -268,6 +270,7 @@ mod remote_test; mod remove_test; mod reset_test; mod restore_test; +mod rev_parse_test; mod revert_test; mod shortlog_test; mod show_ref_test; diff --git a/tests/command/reset_test.rs b/tests/command/reset_test.rs index cd7ff524f..aa47894df 100644 --- a/tests/command/reset_test.rs +++ b/tests/command/reset_test.rs @@ -6,6 +6,8 @@ use std::fs; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; +#[cfg(unix)] +use libra::utils::error::StableErrorCode; use libra::{ command::{ branch::{self, BranchArgs}, @@ -14,7 +16,7 @@ use libra::{ status::{changes_to_be_committed, changes_to_be_staged}, }, internal::{branch::Branch as InternalBranch, config::ConfigKv}, - utils::{error::StableErrorCode, test::setup_with_new_libra_in}, + utils::test::setup_with_new_libra_in, }; use super::*; diff --git a/tests/command/rev_parse_test.rs b/tests/command/rev_parse_test.rs new file mode 100644 index 000000000..f3006b065 --- /dev/null +++ b/tests/command/rev_parse_test.rs @@ -0,0 +1,109 @@ +//! Integration tests for `rev-parse` command. +//! +//! **Layer:** L1 — deterministic, no external dependencies. + +use super::*; + +#[test] +fn test_rev_parse_head_resolves_commit() { + let repo = create_committed_repo_via_cli(); + + let output = run_libra_command(&["rev-parse", "HEAD"], repo.path()); + assert_cli_success(&output, "rev-parse HEAD"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let value = stdout.trim(); + assert_eq!(value.len(), 40, "expected full hash, got: {value}"); + assert!(value.chars().all(|c| c.is_ascii_hexdigit())); +} + +#[test] +fn test_rev_parse_short_head_returns_non_ambiguous_hash() { + let repo = create_committed_repo_via_cli(); + + let full = run_libra_command(&["rev-parse", "HEAD"], repo.path()); + assert_cli_success(&full, "rev-parse HEAD (full)"); + let full_hash = String::from_utf8_lossy(&full.stdout).trim().to_string(); + + let output = run_libra_command(&["rev-parse", "--short", "HEAD"], repo.path()); + assert_cli_success(&output, "rev-parse --short HEAD"); + + let short_hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert!( + short_hash.len() >= 7, + "expected abbreviated hash, got: {short_hash}" + ); + assert!(short_hash.len() <= full_hash.len()); + assert!(full_hash.starts_with(&short_hash)); + + let resolved = run_libra_command(&["rev-parse", short_hash.as_str()], repo.path()); + assert_cli_success(&resolved, "rev-parse "); + assert_eq!(String::from_utf8_lossy(&resolved.stdout).trim(), full_hash); +} + +#[test] +fn test_rev_parse_abbrev_ref_head_returns_branch_name() { + let repo = create_committed_repo_via_cli(); + + let output = run_libra_command(&["rev-parse", "--abbrev-ref", "HEAD"], repo.path()); + assert_cli_success(&output, "rev-parse --abbrev-ref HEAD"); + + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "main"); +} + +#[test] +fn test_rev_parse_show_toplevel_returns_repo_root() { + let repo = create_committed_repo_via_cli(); + + let output = run_libra_command(&["rev-parse", "--show-toplevel"], repo.path()); + assert_cli_success(&output, "rev-parse --show-toplevel"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout.trim(), repo.path().to_string_lossy()); +} + +#[test] +fn test_rev_parse_invalid_target_returns_cli_error_code() { + let repo = create_committed_repo_via_cli(); + + let output = run_libra_command(&["rev-parse", "badref"], repo.path()); + let (stderr, report) = parse_cli_error_stderr(&output.stderr); + + assert_eq!(output.status.code(), Some(129)); + assert!(stderr.contains("not a valid object name: 'badref'")); + assert_eq!(report.error_code, "LBR-CLI-003"); +} + +#[test] +fn test_rev_parse_json_returns_envelope() { + let repo = create_committed_repo_via_cli(); + + let output = run_libra_command(&["--json", "rev-parse", "HEAD"], repo.path()); + assert_cli_success(&output, "json rev-parse HEAD"); + + let json = parse_json_stdout(&output); + assert_eq!(json["ok"], true); + assert_eq!(json["command"], "rev-parse"); + assert_eq!(json["data"]["mode"], "resolve"); + assert_eq!(json["data"]["input"], "HEAD"); + assert!(json["data"]["value"].as_str().is_some()); +} + +#[test] +fn test_rev_parse_machine_returns_single_json_line() { + let repo = create_committed_repo_via_cli(); + + let output = run_libra_command(&["--machine", "rev-parse", "HEAD"], repo.path()); + assert_cli_success(&output, "machine rev-parse HEAD"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout.lines().count(), + 1, + "expected one JSON line, got: {stdout}" + ); + + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).expect("expected JSON"); + assert_eq!(parsed["command"], "rev-parse"); + assert_eq!(parsed["data"]["mode"], "resolve"); +} diff --git a/tests/command/show_ref_test.rs b/tests/command/show_ref_test.rs index 2f0601cea..7ce7d22a4 100644 --- a/tests/command/show_ref_test.rs +++ b/tests/command/show_ref_test.rs @@ -2,7 +2,7 @@ //! //! **Layer:** L1 — deterministic, no external dependencies. -use std::{fs, io::Write, process::Command}; +use std::{fs, io::Write}; use libra::internal::{branch::Branch, db::get_db_conn_instance, model::reference}; use sea_orm::{ActiveModelTrait, Set}; @@ -47,11 +47,7 @@ async fn test_show_ref_empty_repo() { let temp = tempdir().unwrap(); test::setup_with_new_libra_in(temp.path()).await; - let output = Command::new(env!("CARGO_BIN_EXE_libra")) - .current_dir(temp.path()) - .arg("show-ref") - .output() - .expect("failed to execute `libra show-ref`"); + let output = run_libra_command(&["show-ref"], temp.path()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( @@ -70,12 +66,7 @@ async fn test_show_ref_lists_branch() { let head_commit = Head::current_commit().await.unwrap(); - let output = Command::new(env!("CARGO_BIN_EXE_libra")) - .current_dir(temp.path()) - .arg("show-ref") - .arg("--heads") - .output() - .unwrap(); + let output = run_libra_command(&["show-ref", "--heads"], temp.path()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( @@ -132,12 +123,7 @@ async fn test_show_ref_surfaces_corrupt_branch_storage() { .await .unwrap(); - let output = Command::new(env!("CARGO_BIN_EXE_libra")) - .current_dir(temp.path()) - .arg("show-ref") - .arg("--heads") - .output() - .unwrap(); + let output = run_libra_command(&["show-ref", "--heads"], temp.path()); let (stderr, report) = parse_cli_error_stderr(&output.stderr); assert_eq!(output.status.code(), Some(128)); @@ -160,12 +146,7 @@ async fn test_show_ref_lists_tag() { .await .unwrap(); - let output = Command::new(env!("CARGO_BIN_EXE_libra")) - .current_dir(temp.path()) - .arg("show-ref") - .arg("--tags") - .output() - .unwrap(); + let output = run_libra_command(&["show-ref", "--tags"], temp.path()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( @@ -191,12 +172,7 @@ async fn test_show_ref_surfaces_corrupt_tag_storage() { .await .unwrap(); - let output = Command::new(env!("CARGO_BIN_EXE_libra")) - .current_dir(temp.path()) - .arg("show-ref") - .arg("--tags") - .output() - .unwrap(); + let output = run_libra_command(&["show-ref", "--tags"], temp.path()); let (stderr, report) = parse_cli_error_stderr(&output.stderr); assert_eq!(output.status.code(), Some(128)); @@ -216,12 +192,7 @@ async fn test_show_ref_includes_head() { let head_commit = Head::current_commit().await.unwrap(); - let output = Command::new(env!("CARGO_BIN_EXE_libra")) - .current_dir(temp.path()) - .arg("show-ref") - .arg("--head") - .output() - .unwrap(); + let output = run_libra_command(&["show-ref", "--head"], temp.path()); let stdout = String::from_utf8_lossy(&output.stdout); // First line should be HEAD @@ -245,12 +216,7 @@ async fn test_show_ref_hash_only() { let head_commit = Head::current_commit().await.unwrap(); - let output = Command::new(env!("CARGO_BIN_EXE_libra")) - .current_dir(temp.path()) - .arg("show-ref") - .arg("--hash") - .output() - .unwrap(); + let output = run_libra_command(&["show-ref", "--hash"], temp.path()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( @@ -271,12 +237,7 @@ async fn test_show_ref_pattern_no_match() { let temp = tempdir().unwrap(); let _guard = setup_repo_with_commit(&temp).await; - let output = Command::new(env!("CARGO_BIN_EXE_libra")) - .current_dir(temp.path()) - .arg("show-ref") - .arg("nonexistent-xyz") - .output() - .unwrap(); + let output = run_libra_command(&["show-ref", "nonexistent-xyz"], temp.path()); let (stderr, report) = parse_cli_error_stderr(&output.stderr); assert_eq!(output.status.code(), Some(129)); @@ -300,13 +261,7 @@ async fn test_show_ref_pattern_match() { .await .unwrap(); - let output = Command::new(env!("CARGO_BIN_EXE_libra")) - .current_dir(temp.path()) - .arg("show-ref") - .arg("--heads") - .arg("main") - .output() - .unwrap(); + let output = run_libra_command(&["show-ref", "--heads", "main"], temp.path()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( @@ -330,11 +285,7 @@ async fn test_show_ref_default_shows_both() { .await .unwrap(); - let output = Command::new(env!("CARGO_BIN_EXE_libra")) - .current_dir(temp.path()) - .arg("show-ref") - .output() - .unwrap(); + let output = run_libra_command(&["show-ref"], temp.path()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( @@ -351,13 +302,7 @@ async fn test_show_ref_head_exempt_from_pattern_filter() { let temp = tempdir().unwrap(); let _guard = setup_repo_with_commit(&temp).await; - let output = Command::new(env!("CARGO_BIN_EXE_libra")) - .current_dir(temp.path()) - .arg("show-ref") - .arg("--head") - .arg("main") - .output() - .unwrap(); + let output = run_libra_command(&["show-ref", "--head", "main"], temp.path()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( diff --git a/tests/command/tag_test.rs b/tests/command/tag_test.rs index afa8bfed7..1623f2655 100644 --- a/tests/command/tag_test.rs +++ b/tests/command/tag_test.rs @@ -6,16 +6,15 @@ use std::collections::HashSet; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; +#[cfg(unix)] +use libra::utils::path; use libra::{ command::tag::{self, TagArgs}, internal::{ branch::Branch, config::ConfigKv, db::get_db_conn_instance, model::reference, tag as internal_tag, }, - utils::{ - path, - test::{ChangeDirGuard, setup_with_new_libra_in}, - }, + utils::test::{ChangeDirGuard, setup_with_new_libra_in}, }; use sea_orm::{ActiveModelTrait, Set}; use serial_test::serial;