Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 139 additions & 26 deletions src-tauri/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,65 @@ async fn run_git_command(repo_root: &Path, args: &[&str]) -> Result<(), String>
Err(detail.to_string())
}

fn action_paths_for_file(repo_root: &Path, path: &str) -> Vec<String> {
let target = normalize_git_path(path).trim().to_string();
if target.is_empty() {
return Vec::new();
}

let repo = match Repository::open(repo_root) {
Ok(repo) => repo,
Err(_) => return vec![target],
};

let mut status_options = StatusOptions::new();
status_options
.include_untracked(true)
.recurse_untracked_dirs(true)
.renames_head_to_index(true)
.renames_index_to_workdir(true)
.include_ignored(false);

let statuses = match repo.statuses(Some(&mut status_options)) {
Ok(statuses) => statuses,
Err(_) => return vec![target],
};

for entry in statuses.iter() {
let status = entry.status();
if !(status.contains(Status::WT_RENAMED) || status.contains(Status::INDEX_RENAMED)) {
continue;
}
let delta = entry.index_to_workdir().or_else(|| entry.head_to_index());
let Some(delta) = delta else {
continue;
};
let (Some(old_path), Some(new_path)) =
(delta.old_file().path(), delta.new_file().path())
else {
continue;
};
let old_path = normalize_git_path(old_path.to_string_lossy().as_ref());
let new_path = normalize_git_path(new_path.to_string_lossy().as_ref());
if old_path != target && new_path != target {
continue;
}
if old_path == new_path || new_path.is_empty() {
return vec![target];
}
let mut result = Vec::new();
if !old_path.is_empty() {
result.push(old_path);
}
if !new_path.is_empty() && !result.contains(&new_path) {
result.push(new_path);
}
return if result.is_empty() { vec![target] } else { result };
}

vec![target]
}

fn parse_upstream_ref(name: &str) -> Option<(String, String)> {
let trimmed = name.strip_prefix("refs/remotes/").unwrap_or(name);
let mut parts = trimmed.splitn(2, '/');
Expand Down Expand Up @@ -459,26 +518,35 @@ pub(crate) async fn stage_git_file(
path: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let workspaces = state.workspaces.lock().await;
let entry = workspaces
.get(&workspace_id)
.ok_or("workspace not found")?
.clone();
let entry = {
let workspaces = state.workspaces.lock().await;
workspaces
.get(&workspace_id)
.cloned()
.ok_or("workspace not found")?
};

let repo_root = resolve_git_root(&entry)?;
run_git_command(&repo_root, &["add", "--", &path]).await
// If libgit2 reports a rename, we want a single UI action to stage both the
// old + new paths so the change actually moves to the staged section.
for path in action_paths_for_file(&repo_root, &path) {
run_git_command(&repo_root, &["add", "-A", "--", &path]).await?;
}
Ok(())
}

#[tauri::command]
pub(crate) async fn stage_git_all(
workspace_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let workspaces = state.workspaces.lock().await;
let entry = workspaces
.get(&workspace_id)
.ok_or("workspace not found")?
.clone();
let entry = {
let workspaces = state.workspaces.lock().await;
workspaces
.get(&workspace_id)
.cloned()
.ok_or("workspace not found")?
};

let repo_root = resolve_git_root(&entry)?;
run_git_command(&repo_root, &["add", "-A"]).await
Expand All @@ -490,14 +558,19 @@ pub(crate) async fn unstage_git_file(
path: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let workspaces = state.workspaces.lock().await;
let entry = workspaces
.get(&workspace_id)
.ok_or("workspace not found")?
.clone();
let entry = {
let workspaces = state.workspaces.lock().await;
workspaces
.get(&workspace_id)
.cloned()
.ok_or("workspace not found")?
};

let repo_root = resolve_git_root(&entry)?;
run_git_command(&repo_root, &["restore", "--staged", "--", &path]).await
for path in action_paths_for_file(&repo_root, &path) {
run_git_command(&repo_root, &["restore", "--staged", "--", &path]).await?;
}
Ok(())
}

#[tauri::command]
Expand All @@ -506,20 +579,28 @@ pub(crate) async fn revert_git_file(
path: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let workspaces = state.workspaces.lock().await;
let entry = workspaces
.get(&workspace_id)
.ok_or("workspace not found")?
.clone();
let entry = {
let workspaces = state.workspaces.lock().await;
workspaces
.get(&workspace_id)
.cloned()
.ok_or("workspace not found")?
};

let repo_root = resolve_git_root(&entry)?;
if run_git_command(&repo_root, &["restore", "--staged", "--worktree", "--", &path])
for path in action_paths_for_file(&repo_root, &path) {
if run_git_command(
&repo_root,
&["restore", "--staged", "--worktree", "--", &path],
)
.await
.is_ok()
{
return Ok(());
{
continue;
}
run_git_command(&repo_root, &["clean", "-f", "--", &path]).await?;
}
run_git_command(&repo_root, &["clean", "-f", "--", &path]).await
Ok(())
}

#[tauri::command]
Expand Down Expand Up @@ -1244,4 +1325,36 @@ mod tests {
assert!(diff.contains("unstaged.txt"));
assert!(diff.contains("unstaged"));
}

#[test]
fn action_paths_for_file_expands_renames() {
let (root, repo) = create_temp_repo();
fs::write(root.join("a.txt"), "hello\n").expect("write file");

let mut index = repo.index().expect("repo index");
index
.add_path(Path::new("a.txt"))
.expect("add path");
let tree_id = index.write_tree().expect("write tree");
let tree = repo.find_tree(tree_id).expect("find tree");
let sig =
git2::Signature::now("Test", "test@example.com").expect("signature");
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.expect("commit");

fs::rename(root.join("a.txt"), root.join("b.txt")).expect("rename file");

// Stage the rename so libgit2 reports it as an INDEX_RENAMED entry.
let mut index = repo.index().expect("repo index");
index
.remove_path(Path::new("a.txt"))
.expect("remove old path");
index
.add_path(Path::new("b.txt"))
.expect("add new path");
index.write().expect("write index");

let paths = action_paths_for_file(&root, "b.txt");
assert_eq!(paths, vec!["a.txt".to_string(), "b.txt".to_string()]);
}
}
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ function MainApp() {
applyWorktreeChanges: handleApplyWorktreeChanges,
revertAllGitChanges: handleRevertAllGitChanges,
revertGitFile: handleRevertGitFile,
stageGitAll: handleStageGitAll,
stageGitFile: handleStageGitFile,
unstageGitFile: handleUnstageGitFile,
worktreeApplyError,
Expand Down Expand Up @@ -1438,6 +1439,7 @@ function MainApp() {
void handleSetGitRoot(null);
},
onPickGitRoot: handlePickGitRoot,
onStageGitAll: handleStageGitAll,
onStageGitFile: handleStageGitFile,
onUnstageGitFile: handleUnstageGitFile,
onRevertGitFile: handleRevertGitFile,
Expand Down
21 changes: 21 additions & 0 deletions src/features/git/components/GitDiffPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ vi.mock("@tauri-apps/plugin-opener", () => ({
openUrl: vi.fn(),
}));

vi.mock("@tauri-apps/plugin-dialog", () => ({
ask: vi.fn(async () => true),
}));

const logEntries: GitLogEntry[] = [];

const baseProps = {
Expand Down Expand Up @@ -65,4 +69,21 @@ describe("GitDiffPanel", () => {
expect(onCommit).toHaveBeenCalledTimes(1);
});

it("exposes revert-all action from the header", () => {
const onRevertAllChanges = vi.fn();
render(
<GitDiffPanel
{...baseProps}
onRevertAllChanges={onRevertAllChanges}
unstagedFiles={[
{ path: "file.txt", status: "M", additions: 1, deletions: 0 },
]}
/>,
);

const revertAllButton = screen.getByRole("button", { name: "Revert all changes" });
fireEvent.click(revertAllButton);
expect(onRevertAllChanges).toHaveBeenCalledTimes(1);
});

});
Loading