use std::path::{Path, PathBuf};
use processkit::ProcessRunner;
use vcs_jj::{ChangedPath, Jj, JjApi, JjFileset, WorkspaceAdd};
use crate::dto::{ChangeKind, CreateOutcome, DiffStat, FileChange, WorktreeInfo};
use crate::error::{Error, Result};
pub(crate) async fn current_branch<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<Option<String>> {
Ok(jj.current_bookmark(dir).await?)
}
pub(crate) async fn trunk<R: ProcessRunner>(jj: &Jj<R>, dir: &Path) -> Result<Option<String>> {
Ok(jj.trunk(dir).await?)
}
pub(crate) async fn local_branches<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<Vec<String>> {
Ok(jj
.bookmarks(dir)
.await?
.into_iter()
.map(|b| b.name)
.collect())
}
pub(crate) async fn branch_exists<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
name: &str,
) -> Result<bool> {
Ok(jj.bookmarks(dir).await?.iter().any(|b| b.name == name))
}
pub(crate) async fn has_uncommitted_changes<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<bool> {
Ok(!jj.current_change(dir).await?.empty)
}
pub(crate) async fn delete_branch<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
name: &str,
) -> Result<()> {
jj.bookmark_delete(dir, name).await?;
Ok(())
}
pub(crate) async fn rename_branch<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
old: &str,
new: &str,
) -> Result<()> {
jj.bookmark_rename(dir, old, new).await?;
Ok(())
}
pub(crate) async fn changed_files<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<Vec<FileChange>> {
let entries = jj.status(dir).await?;
Ok(entries.into_iter().map(file_change_from_summary).collect())
}
pub(crate) async fn diff_stat<R: ProcessRunner>(jj: &Jj<R>, dir: &Path) -> Result<DiffStat> {
let stat = jj.diff_stat(dir, "@").await?;
Ok(DiffStat {
files_changed: stat.files_changed,
insertions: stat.insertions,
deletions: stat.deletions,
})
}
pub(crate) async fn commit_paths<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
paths: &[String],
message: &str,
) -> Result<()> {
let filesets: Vec<JjFileset> = paths.iter().map(JjFileset::path).collect();
jj.commit_paths(dir, &filesets, message).await?;
Ok(())
}
pub(crate) async fn fetch<R: ProcessRunner>(jj: &Jj<R>, dir: &Path) -> Result<()> {
jj.git_fetch(dir).await?;
Ok(())
}
pub(crate) async fn list_worktrees<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<Vec<WorktreeInfo>> {
let workspaces = jj.workspace_list(dir).await?;
let mut out = Vec::new();
for ws in workspaces {
let Ok(root) = jj.workspace_root(dir, Some(ws.name.clone())).await else {
continue; };
out.push(WorktreeInfo {
path: root,
branch: ws.bookmarks.into_iter().next(),
commit: (!ws.commit.is_empty()).then_some(ws.commit),
is_bare: false,
});
}
Ok(out)
}
pub(crate) async fn create_worktree<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
path: &Path,
branch: &str,
base: &str,
) -> Result<CreateOutcome> {
let ws_name = workspace_name_for(branch);
jj.workspace_add(dir, WorkspaceAdd::new(ws_name.clone(), base, path))
.await?;
let revset = format!("{ws_name}@");
jj.bookmark_create(dir, branch, &revset).await?;
Ok(CreateOutcome::Plain)
}
pub(crate) async fn remove_worktree<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
path: &Path,
_force: bool,
) -> Result<()> {
let name = workspace_name_for_path(jj, dir, path).await?;
if path.exists() {
std::fs::remove_dir_all(path)?;
}
let _ = jj.workspace_forget(dir, &name).await;
Ok(())
}
fn workspace_name_for(branch: &str) -> String {
branch
.chars()
.map(|c| match c {
'/' | '\\' | '.' | ':' | ' ' | '\t' | '\n' | '\r' => '_',
other => other,
})
.collect()
}
async fn workspace_name_for_path<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
path: &Path,
) -> Result<String> {
let target = normalize_for_compare(path);
for ws in jj.workspace_list(dir).await? {
let Ok(root) = jj.workspace_root(dir, Some(ws.name.clone())).await else {
continue;
};
if normalize_for_compare(&root) == target || root == path {
return Ok(ws.name);
}
}
Err(Error::WorktreeNotFound(path.to_path_buf()))
}
fn normalize_for_compare(p: &Path) -> PathBuf {
let canonical = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
#[cfg(windows)]
{
let s = canonical.to_string_lossy();
if let Some(rest) = s.strip_prefix(r"\\?\")
&& !rest.starts_with("UNC\\")
{
return PathBuf::from(rest.to_string());
}
}
canonical
}
fn file_change_from_summary(entry: ChangedPath) -> FileChange {
FileChange {
kind: change_kind_from_status(entry.status),
path: entry.path,
old_path: None,
}
}
fn change_kind_from_status(status: char) -> ChangeKind {
match status {
'A' | 'C' => ChangeKind::Added,
'D' => ChangeKind::Deleted,
'R' => ChangeKind::Renamed,
_ => ChangeKind::Modified,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_name_substitutes_invalid_chars() {
assert_eq!(workspace_name_for("feature/x.y"), "feature_x_y");
assert_eq!(workspace_name_for("plain"), "plain");
}
#[test]
fn summary_status_maps_to_change_kind() {
assert_eq!(change_kind_from_status('M'), ChangeKind::Modified);
assert_eq!(change_kind_from_status('A'), ChangeKind::Added);
assert_eq!(change_kind_from_status('C'), ChangeKind::Added);
assert_eq!(change_kind_from_status('D'), ChangeKind::Deleted);
assert_eq!(change_kind_from_status('R'), ChangeKind::Renamed);
}
}