use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::error::{Result, RototoError};
pub fn workspace_root(source: &str) -> Result<PathBuf> {
if let Some(path) = source.strip_prefix("file://") {
return Ok(PathBuf::from(path));
}
if source.contains("://") {
return Err(RototoError::new(
"local git writes require a local path or file:// workspace source",
));
}
Ok(PathBuf::from(source))
}
pub async fn current_branch(source: &str) -> Result<String> {
let root = workspace_root(source)?;
current_branch_at(&root).await
}
pub async fn current_branch_at(root: &Path) -> Result<String> {
let output = git(root, &["rev-parse", "--abbrev-ref", "HEAD"]).await?;
let branch = output.trim();
if branch.is_empty() || branch == "HEAD" {
return Err(RototoError::new(
"local workspace is not on a named git branch",
));
}
Ok(branch.to_owned())
}
pub async fn head_commit(root: &Path) -> Result<String> {
let output = git(root, &["rev-parse", "HEAD"]).await?;
let commit = output.trim();
if commit.len() != 40 || !commit.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(RototoError::new(
"local workspace HEAD is not a full git commit",
));
}
Ok(commit.to_owned())
}
pub async fn changed_paths(root: &Path, scope: &str) -> Result<Vec<String>> {
let scope = if scope.trim().is_empty() {
"."
} else {
scope.trim()
};
let mut paths = BTreeSet::new();
let diff = git(root, &["diff", "--name-only", "HEAD", "--", scope]).await?;
paths.extend(diff.lines().filter_map(repo_relative_path));
let untracked = git(
root,
&["ls-files", "--others", "--exclude-standard", "--", scope],
)
.await?;
paths.extend(untracked.lines().filter_map(repo_relative_path));
Ok(paths.into_iter().collect())
}
pub async fn file_at_head(root: &Path, relative_to_root: &str) -> Result<Option<String>> {
let path = git_path_for_root(root, relative_to_root).await?;
let spec = format!("HEAD:{path}");
match git(root, &["show", &spec]).await {
Ok(contents) => Ok(Some(contents)),
Err(err) if git_show_missing_path(&err.to_string()) => Ok(None),
Err(err) => Err(err),
}
}
async fn git_path_for_root(root: &Path, relative_to_root: &str) -> Result<String> {
let prefix = git(root, &["rev-parse", "--show-prefix"]).await?;
let prefix = prefix.trim();
let relative = relative_to_root
.trim()
.trim_start_matches("./")
.replace('\\', "/");
let path = if prefix.is_empty() {
relative
} else if relative.is_empty() || relative == "." {
prefix.trim_end_matches('/').to_owned()
} else {
format!("{prefix}{relative}")
};
if path.is_empty() || path == "." {
return Err(RototoError::new("git object path cannot be empty"));
}
Ok(path)
}
fn git_show_missing_path(message: &str) -> bool {
message.contains("exists on disk, but not in 'HEAD'")
|| message.contains("does not exist in 'HEAD'")
|| message.contains("fatal: path")
}
fn repo_relative_path(path: &str) -> Option<String> {
let path = path.trim().trim_matches('"').replace('\\', "/");
if path.is_empty()
|| path.starts_with('/')
|| path
.split('/')
.any(|segment| segment.is_empty() || segment == "." || segment == "..")
{
return None;
}
Some(path)
}
async fn git(root: &Path, args: &[&str]) -> Result<String> {
let started = std::time::Instant::now();
let command_label = format!("git {}", args.join(" "));
tracing::debug!(
operation = "process.command",
command = %command_label,
cwd = %root.display(),
"console outbound process call started"
);
let output = tokio::process::Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.await
.map_err(|err| {
tracing::warn!(
operation = "process.command",
command = %command_label,
cwd = %root.display(),
error = %err,
latency_ms = started.elapsed().as_millis(),
"console outbound process call failed to start"
);
RototoError::new(format!("failed to run git {}: {err}", args.join(" ")))
})?;
if output.status.success() {
tracing::info!(
operation = "process.command",
command = %command_label,
cwd = %root.display(),
status = output.status.code(),
latency_ms = started.elapsed().as_millis(),
"console outbound process call completed"
);
return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
tracing::warn!(
operation = "process.command",
command = %command_label,
cwd = %root.display(),
status = output.status.code(),
latency_ms = started.elapsed().as_millis(),
"console outbound process call returned non-zero status"
);
Err(RototoError::new(format!(
"git {} failed: {}",
args.join(" "),
if stderr.is_empty() {
output.status.to_string()
} else {
stderr
}
)))
}
#[cfg(test)]
mod tests {
use std::process::Command;
use tempfile::TempDir;
use super::*;
#[tokio::test]
async fn changed_paths_include_tracked_and_untracked_workspace_files() {
let repo = TempDir::new().expect("repo tempdir");
init_repo(repo.path());
write_file(
&repo
.path()
.join("workspaces/payments/rototo-workspace.toml"),
"schema_version = 1\n",
)
.await;
write_file(
&repo
.path()
.join("workspaces/payments/variables/checkout.toml"),
"schema_version = 1\n",
)
.await;
write_file(
&repo.path().join("workspaces/search/variables/query.toml"),
"schema_version = 1\n",
)
.await;
commit_all(repo.path(), "add workspaces");
write_file(
&repo
.path()
.join("workspaces/payments/variables/checkout.toml"),
"schema_version = 1\ntype = \"bool\"\n",
)
.await;
write_file(
&repo.path().join("workspaces/payments/variables/new.toml"),
"schema_version = 1\n",
)
.await;
write_file(
&repo.path().join("workspaces/search/variables/query.toml"),
"schema_version = 1\ntype = \"string\"\n",
)
.await;
let paths = changed_paths(repo.path(), "workspaces/payments")
.await
.unwrap();
assert_eq!(
paths,
vec![
"workspaces/payments/variables/checkout.toml".to_owned(),
"workspaces/payments/variables/new.toml".to_owned(),
]
);
}
#[tokio::test]
async fn file_at_head_reads_from_nested_workspace_root() {
let repo = TempDir::new().expect("repo tempdir");
init_repo(repo.path());
let workspace = repo.path().join("workspaces/payments");
write_file(
&workspace.join("variables/checkout.toml"),
"schema_version = 1\n",
)
.await;
commit_all(repo.path(), "add checkout");
write_file(
&workspace.join("variables/checkout.toml"),
"schema_version = 1\ntype = \"bool\"\n",
)
.await;
let contents = file_at_head(&workspace, "variables/checkout.toml")
.await
.unwrap();
let missing = file_at_head(&workspace, "variables/missing.toml")
.await
.unwrap();
assert_eq!(contents.as_deref(), Some("schema_version = 1\n"));
assert_eq!(missing, None);
}
async fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.unwrap();
}
tokio::fs::write(path, contents).await.unwrap();
}
fn init_repo(path: &Path) {
run_git(path, &["init", "-b", "main"]);
run_git(path, &["config", "user.email", "console@example.com"]);
run_git(path, &["config", "user.name", "Console Test"]);
}
fn commit_all(path: &Path, message: &str) {
run_git(path, &["add", "."]);
run_git(path, &["commit", "-m", message]);
}
fn run_git(path: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(path)
.status()
.expect("run git");
assert!(status.success(), "git {:?} failed", args);
}
}