feat(git): Add comprehensive worktree management capabilities

This update introduces full support for Git worktrees, allowing users to manage multiple isolated working copies within a single repository. This includes new functionality to list, add, remove, move, lock, and repair worktrees, significantly enhancing the repository's capability to handle parallel development streams.

- Added `GitWorktree` structure definition across API contracts and Rust backend
- Implemented full CRUD operations for worktrees in Tauri commands
- Updated UI components (App.svelte, BranchPanel.svelte) to expose worktree management dialog
This commit is contained in:
Christoph Brandau
2026-07-26 21:47:35 +02:00
parent afa85b97aa
commit 38b7f1a536
9 changed files with 1406 additions and 14 deletions
+374
View File
@@ -68,6 +68,25 @@ pub struct GitBranch {
pub remote: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitWorktree {
pub path: String,
pub head: Option<String>,
pub short_head: Option<String>,
pub branch: Option<String>,
pub bare: bool,
pub detached: bool,
pub locked: bool,
pub lock_reason: Option<String>,
pub prunable: bool,
pub prune_reason: Option<String>,
pub missing: bool,
pub is_main: bool,
pub is_current: bool,
pub clean: bool,
pub changed_files: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitTag {
pub name: String,
@@ -927,6 +946,291 @@ pub fn delete_branch(
status_for_repo(&repo)
}
fn worktree_path_matches(left: &Path, right: &Path) -> bool {
match (fs::canonicalize(left), fs::canonicalize(right)) {
(Ok(left), Ok(right)) => left == right,
_ => left == right,
}
}
fn parse_worktree_porcelain(output: &[u8], current_repo: &Path) -> Vec<GitWorktree> {
#[derive(Default)]
struct Record {
path: Option<String>,
head: Option<String>,
branch: Option<String>,
bare: bool,
detached: bool,
locked: bool,
lock_reason: Option<String>,
prunable: bool,
prune_reason: Option<String>,
}
fn finish_record(rows: &mut Vec<GitWorktree>, record: &mut Record, current_repo: &Path) {
let Some(path) = record.path.take() else {
*record = Record::default();
return;
};
let path_buf = PathBuf::from(&path);
let missing = !path_buf.exists();
let is_current = worktree_path_matches(&path_buf, current_repo);
let head = record.head.take();
let short_head = head
.as_ref()
.map(|value| value.chars().take(8).collect::<String>());
rows.push(GitWorktree {
path,
head,
short_head,
branch: record.branch.take(),
bare: record.bare,
detached: record.detached,
locked: record.locked,
lock_reason: record.lock_reason.take(),
prunable: record.prunable,
prune_reason: record.prune_reason.take(),
missing,
is_main: rows.is_empty(),
is_current,
clean: true,
changed_files: 0,
});
*record = Record::default();
}
let mut rows = Vec::new();
let mut record = Record::default();
for field in output.split(|byte| *byte == 0) {
if field.is_empty() {
finish_record(&mut rows, &mut record, current_repo);
continue;
}
let value = String::from_utf8_lossy(field);
let (key, detail) = value
.split_once(' ')
.map_or((value.as_ref(), None), |(key, detail)| (key, Some(detail)));
match key {
"worktree" => record.path = detail.map(str::to_string),
"HEAD" => record.head = detail.map(str::to_string),
"branch" => {
record.branch = detail.map(|branch| {
branch
.strip_prefix("refs/heads/")
.unwrap_or(branch)
.to_string()
})
}
"bare" => record.bare = true,
"detached" => record.detached = true,
"locked" => {
record.locked = true;
record.lock_reason = detail.map(str::to_string).filter(|value| !value.is_empty());
}
"prunable" => {
record.prunable = true;
record.prune_reason = detail.map(str::to_string).filter(|value| !value.is_empty());
}
_ => {}
}
}
finish_record(&mut rows, &mut record, current_repo);
rows
}
fn worktrees_for_repo(repo: &Path) -> Result<Vec<GitWorktree>, String> {
let output = run_git(repo, ["worktree", "list", "--porcelain", "-z"])?;
let mut worktrees = parse_worktree_porcelain(&output, repo);
for worktree in &mut worktrees {
if worktree.missing {
worktree.clean = false;
continue;
}
if worktree.bare {
continue;
}
match run_git_at(
Path::new(&worktree.path),
["status", "--porcelain=v1", "-z", "--untracked-files=normal"],
"Could not inspect worktree status",
) {
Ok(status) => {
worktree.changed_files = status
.split(|byte| *byte == 0)
.filter(|entry| entry.len() >= 3 && entry[2] == b' ')
.count() as u32;
worktree.clean = worktree.changed_files == 0;
}
Err(_) => {
worktree.clean = false;
}
}
}
Ok(worktrees)
}
#[tauri::command]
pub fn list_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn add_worktree(
path: String,
worktree_path: String,
branch: Option<String>,
new_branch: Option<String>,
start_point: Option<String>,
detached: Option<bool>,
lock: Option<bool>,
) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
let destination = worktree_path.trim();
if destination.is_empty() {
return Err("Choose a folder for the new worktree.".to_string());
}
let branch = branch
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let new_branch = new_branch
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if branch.is_some() && new_branch.is_some() {
return Err("Choose either an existing branch or a new branch.".to_string());
}
let mut args = vec![OsString::from("worktree"), OsString::from("add")];
if lock.unwrap_or(false) {
args.push(OsString::from("--lock"));
}
if detached.unwrap_or(false) {
args.push(OsString::from("--detach"));
}
let target = if let Some(new_branch) = new_branch {
let new_branch = validate_new_branch_name(&repo, &new_branch)?;
args.push(OsString::from("-b"));
args.push(OsString::from(new_branch));
start_point
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(|value| verify_commit(&repo, value))
.transpose()?
} else if let Some(branch) = branch {
Some(validate_existing_local_branch_name(&repo, &branch)?)
} else {
start_point
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(|value| verify_commit(&repo, value))
.transpose()?
};
args.push(OsString::from(destination));
if let Some(target) = target {
args.push(OsString::from(target));
}
run_git(&repo, args)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn remove_worktree(
path: String,
worktree_path: String,
force: Option<bool>,
) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
let worktrees = worktrees_for_repo(&repo)?;
let target = worktrees
.iter()
.find(|worktree| {
worktree_path_matches(Path::new(&worktree.path), Path::new(&worktree_path))
})
.ok_or_else(|| "The selected worktree is no longer registered.".to_string())?;
if target.is_main {
return Err("The main worktree cannot be removed.".to_string());
}
if target.is_current {
return Err("The currently open worktree cannot be removed.".to_string());
}
if target.locked {
return Err("Unlock this worktree before removing it.".to_string());
}
let mut args = vec![OsString::from("worktree"), OsString::from("remove")];
if force.unwrap_or(false) {
args.push(OsString::from("--force"));
}
args.push(OsString::from(worktree_path));
run_git(&repo, args)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn move_worktree(
path: String,
worktree_path: String,
destination: String,
) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
let destination = destination.trim();
if destination.is_empty() {
return Err("Choose a new location for the worktree.".to_string());
}
run_git(
&repo,
[
OsString::from("worktree"),
OsString::from("move"),
OsString::from(worktree_path),
OsString::from(destination),
],
)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn lock_worktree(
path: String,
worktree_path: String,
reason: Option<String>,
) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
let mut args = vec![OsString::from("worktree"), OsString::from("lock")];
if let Some(reason) = reason
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
args.push(OsString::from("--reason"));
args.push(OsString::from(reason));
}
args.push(OsString::from(worktree_path));
run_git(&repo, args)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn unlock_worktree(path: String, worktree_path: String) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
run_git(&repo, ["worktree", "unlock", "--", worktree_path.as_str()])?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn prune_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
run_git(&repo, ["worktree", "prune"])?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn repair_worktree(path: String, worktree_path: String) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
run_git(&repo, ["worktree", "repair", "--", worktree_path.as_str()])?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn create_tag(
path: String,
@@ -6687,6 +6991,76 @@ mod tests {
assert!(result.lines[0].is_uncommitted);
}
#[test]
fn parse_worktree_porcelain_preserves_flags_and_reasons() {
let current = Path::new("/repos/main");
let output = b"worktree /repos/main\0HEAD 1234567890abcdef\0branch refs/heads/main\0\0worktree /repos/feature\0HEAD abcdef1234567890\0detached\0locked external drive\0prunable gitdir file points to non-existent location\0\0";
let rows = parse_worktree_porcelain(output, current);
assert_eq!(rows.len(), 2);
assert!(rows[0].is_main);
assert_eq!(rows[0].branch.as_deref(), Some("main"));
assert_eq!(rows[0].short_head.as_deref(), Some("12345678"));
assert!(rows[1].detached);
assert!(rows[1].locked);
assert_eq!(rows[1].lock_reason.as_deref(), Some("external drive"));
assert!(rows[1].prunable);
}
#[test]
fn worktree_add_list_and_remove_round_trip() {
let repo = init_temp_repo("worktree_round_trip");
let destination = temp_dir("worktree_round_trip_destination");
commit_initial_file(&repo.path);
run_git_test(&repo.path, ["branch", "feature"]);
let rows = add_worktree(
repo.path.to_string_lossy().to_string(),
destination.path.to_string_lossy().to_string(),
Some("feature".to_string()),
None,
None,
Some(false),
Some(false),
)
.expect("worktree should be created");
let linked = rows
.iter()
.find(|row| row.branch.as_deref() == Some("feature"))
.expect("linked worktree should be listed");
assert!(!linked.is_main);
assert!(linked.clean);
let rows = lock_worktree(
repo.path.to_string_lossy().to_string(),
destination.path.to_string_lossy().to_string(),
Some("test lock".to_string()),
)
.expect("worktree should lock");
let linked = rows
.iter()
.find(|row| row.branch.as_deref() == Some("feature"))
.expect("linked worktree should remain listed");
assert!(linked.locked);
assert_eq!(linked.lock_reason.as_deref(), Some("test lock"));
unlock_worktree(
repo.path.to_string_lossy().to_string(),
destination.path.to_string_lossy().to_string(),
)
.expect("worktree should unlock");
let rows = remove_worktree(
repo.path.to_string_lossy().to_string(),
destination.path.to_string_lossy().to_string(),
Some(false),
)
.expect("worktree should be removed");
assert_eq!(rows.len(), 1);
}
#[test]
fn parse_ai_review_accepts_fenced_json_and_normalizes_findings() {
let raw = r#"```json
+21 -12
View File
@@ -6,23 +6,24 @@ mod telemetry;
use badge::set_sync_badge;
use git::{
SearchCancellationState, add_remote, amend_commit, apply_file_patch, cancel_code_search,
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits,
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits,
compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete,
cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag,
diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url,
get_status, init_repository, last_commit_message, list_branches, list_commits,
list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
list_repository_files, list_stashes, list_tags, merge_abort, merge_branch, merge_continue,
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote,
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
restore_files, restore_reflog_entry, restore_to_commit, revert_commit,
run_sequence_editor_if_requested, search_code_introductions, set_branch_upstream, stage_files,
start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit,
unstage_files, update_remote,
list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort,
merge_branch, merge_continue, move_worktree, open_repo_in_explorer, open_repository,
open_repository_bundle, open_repository_file, prune_worktrees, pull, push, push_tag,
read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree,
rename_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
set_branch_upstream, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop,
stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote,
};
use tauri::Manager;
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
@@ -135,6 +136,14 @@ async fn main() {
create_branch,
rename_branch,
delete_branch,
list_worktrees,
add_worktree,
remove_worktree,
move_worktree,
lock_worktree,
unlock_worktree,
prune_worktrees,
repair_worktree,
list_tags,
create_tag,
delete_tag,