Switch to git cli which has better worktree support (#526)
This commit is contained in:
@@ -23,7 +23,6 @@ uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
ts-rs = { workspace = true }
|
||||
async-trait = "0.1"
|
||||
rust-embed = "8.2"
|
||||
pathdiff = "0.2.1"
|
||||
ignore = "0.4"
|
||||
command-group = { version = "5.0", features = ["with-tokio"] }
|
||||
nix = { version = "0.29", features = ["signal", "process"] }
|
||||
|
||||
@@ -588,7 +588,10 @@ impl LocalContainerService {
|
||||
&task_branch,
|
||||
&base_branch,
|
||||
&changed_paths,
|
||||
).map_err(|e| io::Error::other(e.to_string()))? {
|
||||
).map_err(|e| {
|
||||
tracing::error!("Error processing file changes: {}", e);
|
||||
io::Error::other(e.to_string())
|
||||
})? {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
@@ -598,6 +601,7 @@ impl LocalContainerService {
|
||||
.map(|e| e.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
tracing::error!("Filesystem watcher error: {}", error_msg);
|
||||
Err(io::Error::other(error_msg))?;
|
||||
}
|
||||
}
|
||||
@@ -624,6 +628,7 @@ impl LocalContainerService {
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
})
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -268,13 +268,8 @@ pub async fn get_task_attempt_diff(
|
||||
Extension(task_attempt): Extension<TaskAttempt>,
|
||||
State(deployment): State<DeploymentImpl>,
|
||||
// ) -> Result<ResponseJson<ApiResponse<Diff>>, ApiError> {
|
||||
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, BoxError>>>, axum::http::StatusCode>
|
||||
{
|
||||
let stream = deployment
|
||||
.container()
|
||||
.get_diff(&task_attempt)
|
||||
.await
|
||||
.map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, BoxError>>>, ApiError> {
|
||||
let stream = deployment.container().get_diff(&task_attempt).await?;
|
||||
|
||||
Ok(Sse::new(stream.map_err(|e| -> BoxError { e.into() })).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
@@ -22,12 +22,12 @@ ts-rs = { workspace = true }
|
||||
dirs = "5.0"
|
||||
xdg = "3.0"
|
||||
git2 = "0.18"
|
||||
tempfile = "3.21"
|
||||
async-trait = "0.1"
|
||||
libc = "0.2"
|
||||
rust-embed = "8.2"
|
||||
directories = "6.0.0"
|
||||
open = "5.3.2"
|
||||
pathdiff = "0.2.1"
|
||||
ignore = "0.4"
|
||||
command-group = { version = "5.0", features = ["with-tokio"] }
|
||||
openssl-sys = { workspace = true }
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::{collections::HashMap, path::Path};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use git2::{
|
||||
Branch, BranchType, CherrypickOptions, Delta, DiffFindOptions, DiffOptions, Error as GitError,
|
||||
BranchType, CherrypickOptions, Delta, DiffFindOptions, DiffOptions, Error as GitError,
|
||||
FetchOptions, Reference, Remote, Repository, Sort, build::CheckoutBuilder,
|
||||
};
|
||||
use regex;
|
||||
@@ -13,6 +13,7 @@ use utils::diff::{Diff, DiffChangeKind, FileDiffDetails};
|
||||
|
||||
// Import for file ranking functionality
|
||||
use super::file_ranker::FileStat;
|
||||
use super::git_cli::{ChangeType, GitCli, StatusDiffEntry, StatusDiffOptions};
|
||||
use crate::services::github_service::GitHubRepoInfo;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -175,48 +176,20 @@ impl GitService {
|
||||
}
|
||||
|
||||
pub fn commit(&self, path: &Path, message: &str) -> Result<bool, GitServiceError> {
|
||||
let repo = Repository::open(path)?;
|
||||
|
||||
// Check if there are any changes to commit
|
||||
let status = repo.statuses(None)?;
|
||||
|
||||
let has_changes = status.iter().any(|entry| {
|
||||
let flags = entry.status();
|
||||
flags.contains(git2::Status::INDEX_NEW)
|
||||
|| flags.contains(git2::Status::INDEX_MODIFIED)
|
||||
|| flags.contains(git2::Status::INDEX_DELETED)
|
||||
|| flags.contains(git2::Status::WT_NEW)
|
||||
|| flags.contains(git2::Status::WT_MODIFIED)
|
||||
|| flags.contains(git2::Status::WT_DELETED)
|
||||
});
|
||||
|
||||
// Use Git CLI to respect sparse-checkout semantics for staging and commit
|
||||
let git = GitCli::new();
|
||||
let has_changes = git
|
||||
.has_changes(path)
|
||||
.map_err(|e| GitServiceError::InvalidRepository(format!("git status failed: {e}")))?;
|
||||
if !has_changes {
|
||||
tracing::debug!("No changes to commit!");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Get the current HEAD commit
|
||||
let head = repo.head()?;
|
||||
let parent_commit = head.peel_to_commit()?;
|
||||
|
||||
// Stage all has_changes
|
||||
let mut index = repo.index()?;
|
||||
index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
|
||||
index.write()?;
|
||||
|
||||
let tree_id = index.write_tree()?;
|
||||
let tree = repo.find_tree(tree_id)?;
|
||||
|
||||
let signature = repo.signature()?;
|
||||
repo.commit(
|
||||
Some("HEAD"),
|
||||
&signature,
|
||||
&signature,
|
||||
message,
|
||||
&tree,
|
||||
&[&parent_commit],
|
||||
)?;
|
||||
|
||||
git.add_all(path)
|
||||
.map_err(|e| GitServiceError::InvalidRepository(format!("git add failed: {e}")))?;
|
||||
git.commit(path, message)
|
||||
.map_err(|e| GitServiceError::InvalidRepository(format!("git commit failed: {e}")))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -232,31 +205,24 @@ impl GitService {
|
||||
branch_name: _,
|
||||
base_branch,
|
||||
} => {
|
||||
// Use Git CLI to compute diff vs base to avoid sparse false deletions
|
||||
let repo = Repository::open(worktree_path)?;
|
||||
let base_git_branch = GitService::find_branch(&repo, base_branch)?;
|
||||
let base_tree = base_git_branch.get().peel_to_commit()?.tree()?;
|
||||
|
||||
let mut diff_opts = DiffOptions::new();
|
||||
diff_opts
|
||||
.include_untracked(true)
|
||||
.include_typechange(true)
|
||||
.recurse_untracked_dirs(true);
|
||||
|
||||
// Add path filtering if specified
|
||||
if let Some(paths) = path_filter {
|
||||
for path in paths {
|
||||
diff_opts.pathspec(*path);
|
||||
}
|
||||
}
|
||||
|
||||
let mut diff =
|
||||
repo.diff_tree_to_workdir_with_index(Some(&base_tree), Some(&mut diff_opts))?;
|
||||
|
||||
// Enable rename detection
|
||||
let mut find_opts = DiffFindOptions::new();
|
||||
diff.find_similar(Some(&mut find_opts))?;
|
||||
|
||||
self.convert_diff_to_file_diffs(diff, &repo)
|
||||
let git = GitCli::new();
|
||||
let cli_opts = StatusDiffOptions {
|
||||
path_filter: path_filter.map(|fs| fs.iter().map(|s| s.to_string()).collect()),
|
||||
};
|
||||
let entries = git
|
||||
.diff_status(worktree_path, base_branch, cli_opts)
|
||||
.map_err(|e| {
|
||||
GitServiceError::InvalidRepository(format!("git diff failed: {e}"))
|
||||
})?;
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.map(|e| Self::status_entry_to_diff(&repo, &base_tree, e))
|
||||
.collect())
|
||||
}
|
||||
DiffTarget::Branch {
|
||||
repo_path,
|
||||
@@ -518,6 +484,74 @@ impl GitService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create Diff entries from git_cli::StatusDiffEntry
|
||||
/// New Diff format is flattened with change kind, paths, and optional contents.
|
||||
fn status_entry_to_diff(repo: &Repository, base_tree: &git2::Tree, e: StatusDiffEntry) -> Diff {
|
||||
// Map ChangeType to DiffChangeKind
|
||||
let mut change = match e.change {
|
||||
ChangeType::Added => DiffChangeKind::Added,
|
||||
ChangeType::Deleted => DiffChangeKind::Deleted,
|
||||
ChangeType::Modified => DiffChangeKind::Modified,
|
||||
ChangeType::Renamed => DiffChangeKind::Renamed,
|
||||
ChangeType::Copied => DiffChangeKind::Copied,
|
||||
// Treat type changes and unmerged as modified for now
|
||||
ChangeType::TypeChanged | ChangeType::Unmerged => DiffChangeKind::Modified,
|
||||
ChangeType::Unknown(_) => DiffChangeKind::Modified,
|
||||
};
|
||||
|
||||
// Determine old/new paths based on change
|
||||
let (old_path_opt, new_path_opt): (Option<String>, Option<String>) = match e.change {
|
||||
ChangeType::Added => (None, Some(e.path.clone())),
|
||||
ChangeType::Deleted => (Some(e.old_path.unwrap_or(e.path.clone())), None),
|
||||
ChangeType::Modified | ChangeType::TypeChanged | ChangeType::Unmerged => (
|
||||
Some(e.old_path.unwrap_or(e.path.clone())),
|
||||
Some(e.path.clone()),
|
||||
),
|
||||
ChangeType::Renamed | ChangeType::Copied => (e.old_path.clone(), Some(e.path.clone())),
|
||||
ChangeType::Unknown(_) => (e.old_path.clone(), Some(e.path.clone())),
|
||||
};
|
||||
|
||||
// Load old content from base tree if possible
|
||||
let old_content = if let Some(ref oldp) = old_path_opt {
|
||||
let rel = std::path::Path::new(oldp);
|
||||
match base_tree.get_path(rel) {
|
||||
Ok(entry) if entry.kind() == Some(git2::ObjectType::Blob) => repo
|
||||
.find_blob(entry.id())
|
||||
.ok()
|
||||
.and_then(|b| Self::blob_to_string(&b)),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Load new content from filesystem (worktree) when available
|
||||
let new_content = if let Some(ref newp) = new_path_opt {
|
||||
let rel = std::path::Path::new(newp);
|
||||
Self::read_file_to_string(repo, rel)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// If reported as Modified but content is identical, treat as a permission-only change
|
||||
if matches!(change, DiffChangeKind::Modified)
|
||||
&& old_content
|
||||
.as_ref()
|
||||
.zip(new_content.as_ref())
|
||||
.is_none_or(|(o, n)| o == n)
|
||||
{
|
||||
change = DiffChangeKind::PermissionChange;
|
||||
}
|
||||
|
||||
Diff {
|
||||
change,
|
||||
old_path: old_path_opt,
|
||||
new_path: new_path_opt,
|
||||
old_content,
|
||||
new_content,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge changes from a worktree branch back to the main repository
|
||||
pub fn merge_changes(
|
||||
&self,
|
||||
|
||||
329
crates/services/src/services/git_cli.rs
Normal file
329
crates/services/src/services/git_cli.rs
Normal file
@@ -0,0 +1,329 @@
|
||||
use std::{path::Path, process::Command};
|
||||
|
||||
use thiserror::Error;
|
||||
use utils::shell::resolve_executable_path;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum GitCliError {
|
||||
#[error("git executable not found or not runnable")]
|
||||
NotAvailable,
|
||||
#[error("git command failed: {0}")]
|
||||
CommandFailed(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct GitCli;
|
||||
|
||||
/// Parsed change type from `git diff --name-status` output
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ChangeType {
|
||||
Added,
|
||||
Modified,
|
||||
Deleted,
|
||||
Renamed,
|
||||
Copied,
|
||||
TypeChanged,
|
||||
Unmerged,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
/// One entry from a status diff (name-status + paths)
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StatusDiffEntry {
|
||||
pub change: ChangeType,
|
||||
pub path: String,
|
||||
pub old_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StatusDiffOptions {
|
||||
pub path_filter: Option<Vec<String>>, // pathspecs to limit diff
|
||||
}
|
||||
|
||||
impl GitCli {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
/// Ensure `git` is available on PATH
|
||||
pub fn ensure_available(&self) -> Result<(), GitCliError> {
|
||||
let git = resolve_executable_path("git").ok_or(GitCliError::NotAvailable)?;
|
||||
let out = Command::new(&git)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map_err(|_| GitCliError::NotAvailable)?;
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(GitCliError::NotAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `git -C <repo> worktree add <path> <branch>` (optionally creating the branch with -b)
|
||||
pub fn worktree_add(
|
||||
&self,
|
||||
repo_path: &Path,
|
||||
worktree_path: &Path,
|
||||
branch: &str,
|
||||
create_branch: bool,
|
||||
) -> Result<(), GitCliError> {
|
||||
self.ensure_available()?;
|
||||
|
||||
let git = resolve_executable_path("git").ok_or(GitCliError::NotAvailable)?;
|
||||
let mut cmd = Command::new(&git);
|
||||
cmd.arg("-C").arg(repo_path);
|
||||
cmd.arg("worktree").arg("add");
|
||||
if create_branch {
|
||||
cmd.arg("-b").arg(branch);
|
||||
}
|
||||
cmd.arg(worktree_path).arg(branch);
|
||||
|
||||
let out = cmd
|
||||
.output()
|
||||
.map_err(|e| GitCliError::CommandFailed(e.to_string()))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
return Err(GitCliError::CommandFailed(stderr));
|
||||
}
|
||||
|
||||
// Good practice: reapply sparse-checkout in the new worktree to ensure materialization matches
|
||||
// Non-fatal if it fails or not configured.
|
||||
let _ = Command::new(&git)
|
||||
.arg("-C")
|
||||
.arg(worktree_path)
|
||||
.arg("sparse-checkout")
|
||||
.arg("reapply")
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `git -C <repo> worktree remove <path>`
|
||||
pub fn worktree_remove(
|
||||
&self,
|
||||
repo_path: &Path,
|
||||
worktree_path: &Path,
|
||||
force: bool,
|
||||
) -> Result<(), GitCliError> {
|
||||
self.ensure_available()?;
|
||||
let git = resolve_executable_path("git").ok_or(GitCliError::NotAvailable)?;
|
||||
let mut cmd = Command::new(&git);
|
||||
cmd.arg("-C").arg(repo_path);
|
||||
cmd.arg("worktree").arg("remove");
|
||||
if force {
|
||||
cmd.arg("--force");
|
||||
}
|
||||
cmd.arg(worktree_path);
|
||||
|
||||
let out = cmd
|
||||
.output()
|
||||
.map_err(|e| GitCliError::CommandFailed(e.to_string()))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
return Err(GitCliError::CommandFailed(stderr));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prune stale worktree metadata
|
||||
pub fn worktree_prune(&self, repo_path: &Path) -> Result<(), GitCliError> {
|
||||
self.ensure_available()?;
|
||||
let git = resolve_executable_path("git").ok_or(GitCliError::NotAvailable)?;
|
||||
let out = Command::new(&git)
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.arg("worktree")
|
||||
.arg("prune")
|
||||
.output()
|
||||
.map_err(|e| GitCliError::CommandFailed(e.to_string()))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
return Err(GitCliError::CommandFailed(stderr));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return true if there are any changes in the working tree (staged or unstaged).
|
||||
pub fn has_changes(&self, worktree_path: &Path) -> Result<bool, GitCliError> {
|
||||
self.ensure_available()?;
|
||||
let git = resolve_executable_path("git").ok_or(GitCliError::NotAvailable)?;
|
||||
let out = Command::new(&git)
|
||||
.arg("-C")
|
||||
.arg(worktree_path)
|
||||
.arg("status")
|
||||
.arg("--porcelain")
|
||||
.output()
|
||||
.map_err(|e| GitCliError::CommandFailed(e.to_string()))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
return Err(GitCliError::CommandFailed(stderr));
|
||||
}
|
||||
Ok(!out.stdout.is_empty())
|
||||
}
|
||||
|
||||
/// Diff status vs a base branch using a temporary index (always includes untracked).
|
||||
/// Path filter limits the reported paths.
|
||||
pub fn diff_status(
|
||||
&self,
|
||||
worktree_path: &Path,
|
||||
base_branch: &str,
|
||||
opts: StatusDiffOptions,
|
||||
) -> Result<Vec<StatusDiffEntry>, GitCliError> {
|
||||
self.ensure_available()?;
|
||||
let git = resolve_executable_path("git").ok_or(GitCliError::NotAvailable)?;
|
||||
|
||||
// Create a temp index file
|
||||
let tmp_dir = tempfile::TempDir::new()
|
||||
.map_err(|e| GitCliError::CommandFailed(format!("temp dir create failed: {e}")))?;
|
||||
let tmp_index = tmp_dir.path().join("index");
|
||||
|
||||
// Use a temp index from HEAD to accurately track renames in untracked files
|
||||
let seed_out = Command::new(&git)
|
||||
.env("GIT_INDEX_FILE", &tmp_index)
|
||||
.arg("-C")
|
||||
.arg(worktree_path)
|
||||
.arg("read-tree")
|
||||
.arg("HEAD")
|
||||
.output()
|
||||
.map_err(|e| GitCliError::CommandFailed(e.to_string()))?;
|
||||
if !seed_out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&seed_out.stderr).trim().to_string();
|
||||
return Err(GitCliError::CommandFailed(format!(
|
||||
"git read-tree failed: {stderr}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Stage all in temp index
|
||||
let add_out = Command::new(&git)
|
||||
.env("GIT_INDEX_FILE", &tmp_index)
|
||||
.arg("-C")
|
||||
.arg(worktree_path)
|
||||
.arg("add")
|
||||
.arg("-A")
|
||||
.output()
|
||||
.map_err(|e| GitCliError::CommandFailed(e.to_string()))?;
|
||||
if !add_out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&add_out.stderr).trim().to_string();
|
||||
return Err(GitCliError::CommandFailed(stderr));
|
||||
}
|
||||
|
||||
// git diff --cached
|
||||
let mut cmd = Command::new(&git);
|
||||
cmd.env("GIT_INDEX_FILE", &tmp_index)
|
||||
.arg("-C")
|
||||
.arg(worktree_path)
|
||||
.arg("-c")
|
||||
.arg("core.quotepath=false")
|
||||
.arg("diff")
|
||||
.arg("--cached")
|
||||
.arg("-M")
|
||||
.arg("--name-status")
|
||||
.arg(base_branch);
|
||||
if let Some(paths) = &opts.path_filter {
|
||||
let non_empty_paths: Vec<&str> = paths
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.filter(|p| !p.trim().is_empty())
|
||||
.collect();
|
||||
if !non_empty_paths.is_empty() {
|
||||
cmd.arg("--");
|
||||
for p in non_empty_paths {
|
||||
cmd.arg(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
let diff_out = cmd
|
||||
.output()
|
||||
.map_err(|e| GitCliError::CommandFailed(e.to_string()))?;
|
||||
if !diff_out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&diff_out.stderr).trim().to_string();
|
||||
return Err(GitCliError::CommandFailed(stderr));
|
||||
}
|
||||
Ok(Self::parse_name_status(&String::from_utf8_lossy(
|
||||
&diff_out.stdout,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Stage all changes in the working tree (respects sparse-checkout semantics).
|
||||
pub fn add_all(&self, worktree_path: &Path) -> Result<(), GitCliError> {
|
||||
self.ensure_available()?;
|
||||
let git = resolve_executable_path("git").ok_or(GitCliError::NotAvailable)?;
|
||||
let out = Command::new(&git)
|
||||
.arg("-C")
|
||||
.arg(worktree_path)
|
||||
.arg("add")
|
||||
.arg("-A")
|
||||
.output()
|
||||
.map_err(|e| GitCliError::CommandFailed(e.to_string()))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
return Err(GitCliError::CommandFailed(stderr));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit staged changes with the given message.
|
||||
pub fn commit(&self, worktree_path: &Path, message: &str) -> Result<(), GitCliError> {
|
||||
self.ensure_available()?;
|
||||
let git = resolve_executable_path("git").ok_or(GitCliError::NotAvailable)?;
|
||||
let out = Command::new(&git)
|
||||
.arg("-C")
|
||||
.arg(worktree_path)
|
||||
.arg("commit")
|
||||
.arg("-m")
|
||||
.arg(message)
|
||||
.output()
|
||||
.map_err(|e| GitCliError::CommandFailed(e.to_string()))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
return Err(GitCliError::CommandFailed(stderr));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Parse `git diff --name-status` output into structured entries.
|
||||
// Handles rename/copy scores like `R100` by matching the first letter.
|
||||
fn parse_name_status(output: &str) -> Vec<StatusDiffEntry> {
|
||||
let mut out = Vec::new();
|
||||
for line in output.lines() {
|
||||
let line = line.trim_end();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut parts = line.split('\t');
|
||||
let code = parts.next().unwrap_or("");
|
||||
let change = match code.chars().next().unwrap_or('?') {
|
||||
'A' => ChangeType::Added,
|
||||
'M' => ChangeType::Modified,
|
||||
'D' => ChangeType::Deleted,
|
||||
'R' => ChangeType::Renamed,
|
||||
'C' => ChangeType::Copied,
|
||||
'T' => ChangeType::TypeChanged,
|
||||
'U' => ChangeType::Unmerged,
|
||||
other => ChangeType::Unknown(other.to_string()),
|
||||
};
|
||||
|
||||
match change {
|
||||
ChangeType::Renamed | ChangeType::Copied => {
|
||||
if let (Some(old), Some(newp)) = (parts.next(), parts.next()) {
|
||||
out.push(StatusDiffEntry {
|
||||
change,
|
||||
path: newp.to_string(),
|
||||
old_path: Some(old.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(p) = parts.next() {
|
||||
out.push(StatusDiffEntry {
|
||||
change,
|
||||
path: p.to_string(),
|
||||
old_path: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod file_ranker;
|
||||
pub mod filesystem;
|
||||
pub mod filesystem_watcher;
|
||||
pub mod git;
|
||||
pub mod git_cli;
|
||||
pub mod github_service;
|
||||
pub mod image;
|
||||
pub mod notification;
|
||||
|
||||
@@ -4,12 +4,15 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use git2::{Error as GitError, Repository, WorktreeAddOptions};
|
||||
use git2::{Error as GitError, Repository};
|
||||
use thiserror::Error;
|
||||
use tracing::{debug, info, warn};
|
||||
use utils::{is_wsl2, shell::get_shell_command};
|
||||
use tracing::{debug, info};
|
||||
use utils::shell::get_shell_command;
|
||||
|
||||
use super::git::{GitService, GitServiceError};
|
||||
use super::{
|
||||
git::{GitService, GitServiceError},
|
||||
git_cli::GitCli,
|
||||
};
|
||||
|
||||
// Global synchronization for worktree creation to prevent race conditions
|
||||
lazy_static::lazy_static! {
|
||||
@@ -23,6 +26,8 @@ pub enum WorktreeError {
|
||||
Git(#[from] GitError),
|
||||
#[error(transparent)]
|
||||
GitService(#[from] GitServiceError),
|
||||
#[error("Git CLI error: {0}")]
|
||||
GitCli(String),
|
||||
#[error("Task join error: {0}")]
|
||||
TaskJoin(String),
|
||||
#[error("Invalid path: {0}")]
|
||||
@@ -185,23 +190,6 @@ impl WorktreeManager {
|
||||
.map_err(|e| WorktreeError::TaskJoin(format!("{e}")))?
|
||||
}
|
||||
|
||||
/// Try to remove a worktree registration from git
|
||||
fn try_remove_worktree(repo: &Repository, worktree_name: &str) -> Result<(), GitError> {
|
||||
let worktrees = repo.worktrees()?;
|
||||
|
||||
for name in worktrees.iter().flatten() {
|
||||
if name == worktree_name {
|
||||
let worktree = repo.find_worktree(name)?;
|
||||
worktree.prune(None)?;
|
||||
debug!("Successfully removed worktree registration: {}", name);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Worktree {} not found in git worktrees list", worktree_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Comprehensive cleanup of worktree path and metadata to prevent "path exists" errors (blocking)
|
||||
fn comprehensive_worktree_cleanup(
|
||||
repo: &Repository,
|
||||
@@ -212,12 +200,17 @@ impl WorktreeManager {
|
||||
|
||||
let git_repo_path = Self::get_git_repo_path(repo)?;
|
||||
|
||||
// Step 1: Always try to remove worktree registration first (this may fail if not registered)
|
||||
if let Err(e) = Self::try_remove_worktree(repo, worktree_name) {
|
||||
debug!(
|
||||
"Worktree registration removal failed or not found (non-fatal): {}",
|
||||
e
|
||||
);
|
||||
// Try git CLI worktree remove first (force). This tends to be more robust.
|
||||
let git = GitCli::new();
|
||||
if let Err(e) = git.worktree_remove(&git_repo_path, worktree_path, true) {
|
||||
debug!("git worktree remove non-fatal error: {}", e);
|
||||
}
|
||||
|
||||
// Step 1: Use Git CLI to remove the worktree registration (force) if present
|
||||
// The Git CLI is more robust than libgit2 for mutable worktree operations
|
||||
let git = GitCli::new();
|
||||
if let Err(e) = git.worktree_remove(&git_repo_path, worktree_path, true) {
|
||||
debug!("git worktree remove non-fatal error: {}", e);
|
||||
}
|
||||
|
||||
// Step 2: Always force cleanup metadata directory (proactive cleanup)
|
||||
@@ -234,6 +227,11 @@ impl WorktreeManager {
|
||||
std::fs::remove_dir_all(worktree_path).map_err(WorktreeError::Io)?;
|
||||
}
|
||||
|
||||
// Step 4: Good-practice to clean up any other stale admin entries
|
||||
if let Err(e) = git.worktree_prune(&git_repo_path) {
|
||||
debug!("git worktree prune non-fatal error: {}", e);
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Comprehensive cleanup completed for worktree: {}",
|
||||
worktree_name
|
||||
@@ -301,85 +299,46 @@ impl WorktreeManager {
|
||||
let path_str = path_str.to_string();
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<(), WorktreeError> {
|
||||
// Open repository in blocking context
|
||||
let repo = Repository::open(&git_repo_path).map_err(WorktreeError::Git)?;
|
||||
|
||||
// Find the branch reference using the branch name
|
||||
let branch_ref = GitService::find_branch(&repo, &branch_name)?.into_reference();
|
||||
|
||||
// Create worktree options
|
||||
let mut worktree_opts = WorktreeAddOptions::new();
|
||||
worktree_opts.reference(Some(&branch_ref));
|
||||
|
||||
match repo.worktree(&branch_name, &worktree_path, Some(&worktree_opts)) {
|
||||
Ok(_) => {
|
||||
// Verify the worktree was actually created
|
||||
// Prefer git CLI for worktree add to inherit sparse-checkout semantics
|
||||
let git = GitCli::new();
|
||||
match git.worktree_add(&git_repo_path, &worktree_path, &branch_name, false) {
|
||||
Ok(()) => {
|
||||
if !worktree_path.exists() {
|
||||
return Err(WorktreeError::Repository(format!(
|
||||
"Worktree creation reported success but path {path_str} does not exist"
|
||||
)));
|
||||
}
|
||||
|
||||
info!(
|
||||
"Successfully created worktree {} at {}",
|
||||
"Successfully created worktree {} at {} (git CLI)",
|
||||
branch_name, path_str
|
||||
);
|
||||
|
||||
// Fix commondir for Windows/WSL compatibility
|
||||
if let Err(e) = Self::fix_worktree_commondir_for_windows_wsl(
|
||||
Path::new(&git_repo_path),
|
||||
&worktree_name,
|
||||
) {
|
||||
warn!("Failed to fix worktree commondir for Windows/WSL: {}", e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) if e.code() == git2::ErrorCode::Exists => {
|
||||
// Handle the specific "directory exists" error for metadata
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"Worktree metadata directory exists, attempting force cleanup: {}",
|
||||
"git worktree add failed; attempting metadata cleanup and retry: {}",
|
||||
e
|
||||
);
|
||||
|
||||
// Force cleanup metadata and try one more time
|
||||
Self::force_cleanup_worktree_metadata(&git_repo_path, &worktree_name)
|
||||
.map_err(WorktreeError::Io)?;
|
||||
|
||||
// Try again after cleanup
|
||||
match repo.worktree(&branch_name, &worktree_path, Some(&worktree_opts)) {
|
||||
Ok(_) => {
|
||||
if !worktree_path.exists() {
|
||||
return Err(WorktreeError::Repository(format!(
|
||||
"Worktree creation reported success but path {path_str} does not exist"
|
||||
)));
|
||||
}
|
||||
|
||||
info!(
|
||||
"Successfully created worktree {} at {} after metadata cleanup",
|
||||
branch_name, path_str
|
||||
);
|
||||
|
||||
// Fix commondir for Windows/WSL compatibility
|
||||
if let Err(e) = Self::fix_worktree_commondir_for_windows_wsl(
|
||||
Path::new(&git_repo_path),
|
||||
&worktree_name,
|
||||
) {
|
||||
warn!("Failed to fix worktree commondir for Windows/WSL: {}", e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(retry_error) => {
|
||||
debug!(
|
||||
"Worktree creation failed even after metadata cleanup: {}",
|
||||
retry_error
|
||||
);
|
||||
Err(WorktreeError::Git(retry_error))
|
||||
}
|
||||
if let Err(e2) =
|
||||
git.worktree_add(&git_repo_path, &worktree_path, &branch_name, false)
|
||||
{
|
||||
debug!("Retry of git worktree add failed: {}", e2);
|
||||
return Err(WorktreeError::GitCli(e2.to_string()));
|
||||
}
|
||||
if !worktree_path.exists() {
|
||||
return Err(WorktreeError::Repository(format!(
|
||||
"Worktree creation reported success but path {path_str} does not exist"
|
||||
)));
|
||||
}
|
||||
info!(
|
||||
"Successfully created worktree {} at {} after metadata cleanup (git CLI)",
|
||||
branch_name, path_str
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(WorktreeError::Git(e)),
|
||||
}
|
||||
})
|
||||
.await
|
||||
@@ -525,88 +484,6 @@ impl WorktreeManager {
|
||||
.map_err(|e| WorktreeError::TaskJoin(format!("{e}")))?
|
||||
}
|
||||
|
||||
/// Rewrite worktree's commondir file to use relative paths for WSL compatibility
|
||||
///
|
||||
/// This fixes Git repository corruption in WSL environments where git2/libgit2 creates
|
||||
/// worktrees with absolute WSL paths (/mnt/c/...) that Windows Git cannot understand.
|
||||
/// Git CLI creates relative paths (../../..) which work across both environments.
|
||||
///
|
||||
/// References:
|
||||
/// - Git 2.48+ native support: https://git-scm.com/docs/git-config/2.48.0#Documentation/git-config.txt-worktreeuseRelativePaths
|
||||
/// - WSL worktree absolute path issue: https://github.com/git-ecosystem/git-credential-manager/issues/1789
|
||||
pub fn fix_worktree_commondir_for_windows_wsl(
|
||||
git_repo_path: &Path,
|
||||
worktree_name: &str,
|
||||
) -> Result<(), std::io::Error> {
|
||||
if !cfg!(target_os = "linux") || !is_wsl2() {
|
||||
debug!("Skipping commondir fix for non-WSL2 environment");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let commondir_path = git_repo_path
|
||||
.join(".git")
|
||||
.join("worktrees")
|
||||
.join(worktree_name)
|
||||
.join("commondir");
|
||||
|
||||
if !commondir_path.exists() {
|
||||
debug!(
|
||||
"commondir file does not exist: {}",
|
||||
commondir_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Read current commondir content
|
||||
let current_content = std::fs::read_to_string(&commondir_path)?.trim().to_string();
|
||||
|
||||
debug!("Current commondir content: {}", current_content);
|
||||
|
||||
// Skip if already relative
|
||||
if !Path::new(¤t_content).is_absolute() {
|
||||
debug!("commondir already contains relative path, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Calculate relative path from worktree metadata dir to repo .git dir
|
||||
let metadata_dir = commondir_path.parent().unwrap();
|
||||
let target_git_dir = Path::new(¤t_content);
|
||||
|
||||
if let Some(relative_path) = pathdiff::diff_paths(target_git_dir, metadata_dir) {
|
||||
let relative_path_str = relative_path.to_string_lossy();
|
||||
|
||||
// Safety check: ensure the relative path resolves to the same absolute path
|
||||
let resolved_path = metadata_dir.join(&relative_path);
|
||||
if let (Ok(resolved_canonical), Ok(target_canonical)) =
|
||||
(resolved_path.canonicalize(), target_git_dir.canonicalize())
|
||||
{
|
||||
if resolved_canonical == target_canonical {
|
||||
// Write the relative path
|
||||
std::fs::write(&commondir_path, format!("{relative_path_str}\n"))?;
|
||||
info!(
|
||||
"Rewrote commondir to relative path: {} -> {}",
|
||||
current_content, relative_path_str
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"Safety check failed: relative path {} does not resolve to same target",
|
||||
relative_path_str
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warn!("Failed to canonicalize paths for safety check");
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
"Failed to calculate relative path from {} to {}",
|
||||
metadata_dir.display(),
|
||||
target_git_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the base directory for vibe-kanban worktrees
|
||||
pub fn get_worktree_base_dir() -> std::path::PathBuf {
|
||||
utils::path::get_vibe_kanban_temp_dir().join("worktrees")
|
||||
|
||||
@@ -19,6 +19,8 @@ pub fn get_shell_command() -> (&'static str, &'static str) {
|
||||
}
|
||||
|
||||
/// Resolves the full path of an executable using the system's PATH environment variable.
|
||||
/// Note: On Windows, resolving the executable path can be necessary before passing
|
||||
/// it to `std::process::Command::new`, as the latter has been deficient in finding executables.
|
||||
pub fn resolve_executable_path(executable: &str) -> Option<String> {
|
||||
which::which(executable)
|
||||
.ok()
|
||||
|
||||
Reference in New Issue
Block a user