use std::path::{Path, PathBuf};
use processkit::ProcessRunner;
use vcs_jj::{ChangedPath, Jj, JjApi, JjFileset, WorkspaceAdd};
use crate::dto::{
ChangeKind, CreateOutcome, DiffStat, FileChange, MergeProbe, OperationState, RepoSnapshot,
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 conflicted_files<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<Vec<String>> {
Ok(jj.resolve_list(dir, "@").await?)
}
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> {
jj.diff_stat(dir, "@").await.map_err(Into::into)
}
const SNAPSHOT_TEMPLATE: &str = "commit_id ++ \"\\t\" ++ \
local_bookmarks.map(|b| b.name()).join(\",\") ++ \"\\t\" ++ \
if(empty, \"1\", \"0\") ++ \"\\t\" ++ if(conflict, \"1\", \"0\")";
pub(crate) async fn snapshot<R: ProcessRunner>(jj: &Jj<R>, dir: &Path) -> Result<RepoSnapshot> {
let row = jj
.template_query(dir, "@", SNAPSHOT_TEMPLATE, Some(1))
.await?;
let mut fields = row.trim_end_matches(['\r', '\n']).split('\t');
let head = fields.next().filter(|s| !s.is_empty()).map(str::to_string);
let branch = fields
.next()
.and_then(|b| b.split(',').find(|n| !n.is_empty()))
.map(str::to_string);
let dirty = fields.next() != Some("1"); let conflicted = fields.next() == Some("1");
let operation = if conflicted {
OperationState::Conflict
} else {
OperationState::Clear
};
let change_count = if dirty {
jj.status(dir).await?.len()
} else {
0
};
Ok(RepoSnapshot {
head,
branch,
upstream: None,
ahead: None,
behind: None,
dirty,
change_count,
conflicted,
operation,
})
}
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 fetch_from<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
remote: &str,
) -> Result<()> {
jj.git_fetch_from(dir, remote).await?;
Ok(())
}
pub(crate) async fn fetch_remote_branch<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
branch: &str,
) -> Result<()> {
jj.git_fetch_branch(dir, branch).await?;
Ok(())
}
pub(crate) async fn push<R: ProcessRunner>(jj: &Jj<R>, dir: &Path, branch: &str) -> Result<()> {
jj.git_push(dir, Some(branch.to_string())).await?;
Ok(())
}
pub(crate) async fn checkout<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
reference: &str,
) -> Result<()> {
jj.edit(dir, reference).await?;
Ok(())
}
pub(crate) async fn rebase<R: ProcessRunner>(jj: &Jj<R>, dir: &Path, onto: &str) -> Result<()> {
jj.rebase(dir, onto).await?;
Ok(())
}
pub(crate) async fn try_merge<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
source: &str,
) -> Result<MergeProbe> {
let pre_op = jj.op_head(dir).await?;
let merged = jj
.new_merge(
dir,
"vcs-core try_merge probe (rolled back)",
vec!["@".into(), source.into()],
)
.await;
let probe = async {
if jj.is_conflicted(dir, "@").await? {
Ok::<_, vcs_jj::Error>(Some(jj.resolve_list(dir, "@").await?))
} else {
Ok(None)
}
}
.await;
let restored = jj.op_restore(dir, &pre_op).await;
match (merged, probe) {
(Ok(()), Ok(conflicts)) => {
restored?;
Ok(match conflicts {
Some(files) => MergeProbe::Conflicts(files),
None => MergeProbe::Clean,
})
}
(Ok(()), Err(err)) => {
restored?;
Err(err.into())
}
(Err(err), _) => Err(err.into()),
}
}
pub(crate) async fn abort_in_progress<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<OperationState> {
in_progress_state(jj, dir).await
}
pub(crate) async fn continue_in_progress<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<OperationState> {
in_progress_state(jj, dir).await
}
pub(crate) async fn in_progress_state<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<OperationState> {
if jj.has_workingcopy_conflict(dir).await? {
Ok(OperationState::Conflict)
} else {
Ok(OperationState::Clear)
}
}
pub(crate) async fn list_worktrees<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<Vec<WorktreeInfo>> {
let workspaces = jj.workspace_list(dir).await?;
let names: Vec<String> = workspaces.iter().map(|ws| ws.name.clone()).collect();
let roots = jj.workspace_roots(dir, &names).await;
let mut out = Vec::new();
for (ws, root) in workspaces.into_iter().zip(roots) {
let Ok(root) = root 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);
let workspaces = jj.workspace_list(dir).await?;
let names: Vec<String> = workspaces.iter().map(|ws| ws.name.clone()).collect();
let roots = jj.workspace_roots(dir, &names).await;
for (ws, root) in workspaces.into_iter().zip(roots) {
let Ok(root) = root 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: entry.old_path,
}
}
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);
}
}