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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user