Switch to git cli which has better worktree support (#526)

This commit is contained in:
Solomon
2025-08-26 18:33:49 +01:00
committed by GitHub
parent 5d8a209785
commit 042fb2305c
9 changed files with 481 additions and 239 deletions

View File

@@ -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"] }

View File

@@ -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()
}

View File

@@ -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()))
}

View File

@@ -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 }

View File

@@ -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,

View 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
}
}

View File

@@ -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;

View File

@@ -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(&current_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(&current_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")

View File

@@ -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()