diff --git a/Cargo.lock b/Cargo.lock index 5697687a02..a8bf5e0f3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2649,10 +2649,12 @@ dependencies = [ "base64 0.22.1", "bstr", "git2", + "git2-hooks", "gitbutler-cherry-pick", "gitbutler-command-context", "gitbutler-commit", "gitbutler-config", + "gitbutler-diff", "gitbutler-error", "gitbutler-oxidize", "gitbutler-project", @@ -2828,6 +2830,7 @@ dependencies = [ "gitbutler-user", "gitbutler-watcher", "gix", + "itertools 0.14.0", "log", "once_cell", "open", diff --git a/apps/desktop/src/lib/commit/CommitDialog.svelte b/apps/desktop/src/lib/commit/CommitDialog.svelte index 81fe116612..638501c97a 100644 --- a/apps/desktop/src/lib/commit/CommitDialog.svelte +++ b/apps/desktop/src/lib/commit/CommitDialog.svelte @@ -5,6 +5,7 @@ import { persistedCommitMessage, projectRunCommitHooks } from '$lib/config/config'; import { cloudCommunicationFunctionality } from '$lib/config/uiFeatureFlags'; import { SyncedSnapshotService } from '$lib/history/syncedSnapshotService'; + import { showError } from '$lib/notifications/toasts'; import DropDownButton from '$lib/shared/DropDownButton.svelte'; import { intersectionObserver } from '$lib/utils/intersectionObserver'; import { BranchController } from '$lib/vbranches/branchController'; @@ -35,6 +36,7 @@ let commitMessageInput = $state(); let isCommitting = $state(false); + let isRunningHooks = $state(false); let commitMessageValid = $state(false); let isInViewport = $state(false); @@ -44,7 +46,6 @@ try { await branchController.commitBranch( $stack.id, - $stack.name, message.trim(), $selectedOwnership.toString(), $runCommitHooks @@ -59,6 +60,17 @@ } } + async function runHooks() { + isRunningHooks = true; + try { + await branchController.runHooks(projectId, $selectedOwnership.toString()); + } catch (err: unknown) { + showError('Failed to run hooks', err); + } finally { + isRunningHooks = false; + } + } + function close() { $expanded = false; } @@ -106,6 +118,20 @@ {#if $expanded && !isCommitting}
+ {#if $expanded} + + {/if}
{/if} {#if $expanded && canShowCommitAndPublish} @@ -197,6 +223,7 @@ .cancel-btn-wrapper { overflow: hidden; margin-right: 6px; + white-space: nowrap; } /* MODIFIERS */ diff --git a/apps/desktop/src/lib/shared/InfoMessage.svelte b/apps/desktop/src/lib/shared/InfoMessage.svelte index 12385ce38a..ee98c1ca88 100644 --- a/apps/desktop/src/lib/shared/InfoMessage.svelte +++ b/apps/desktop/src/lib/shared/InfoMessage.svelte @@ -218,6 +218,7 @@ color: var(--clr-scale-err-10); border-radius: var(--radius-s); font-size: 12px; + white-space: pre; /* scrollbar */ &::-webkit-scrollbar { diff --git a/apps/desktop/src/lib/vbranches/branchController.ts b/apps/desktop/src/lib/vbranches/branchController.ts index 1024781a27..c8717322ea 100644 --- a/apps/desktop/src/lib/vbranches/branchController.ts +++ b/apps/desktop/src/lib/vbranches/branchController.ts @@ -52,9 +52,16 @@ export class BranchController { } } + async runHooks(stackId: string, ownership: string) { + await invoke('run_hooks', { + projectId: this.projectId, + stackId, + ownership + }); + } + async commitBranch( branchId: string, - branchName: string, message: string, ownership: string | undefined = undefined, runHooks = false @@ -73,7 +80,6 @@ export class BranchController { showSignError(err); } else { showError('Failed to commit changes', err); - throw err; } this.posthog.capture('Commit Failed', err); } diff --git a/crates/gitbutler-branch-actions/src/actions.rs b/crates/gitbutler-branch-actions/src/actions.rs index 68e8436734..f8f6adce11 100644 --- a/crates/gitbutler-branch-actions/src/actions.rs +++ b/crates/gitbutler-branch-actions/src/actions.rs @@ -29,6 +29,7 @@ use gitbutler_oplog::{ }; use gitbutler_project::{FetchResult, Project}; use gitbutler_reference::{ReferenceName, Refname, RemoteRefname}; +use gitbutler_repo::staging; use gitbutler_repo::RepositoryExt; use gitbutler_repo_actions::RepoActionsExt; use gitbutler_stack::{BranchOwnershipClaims, StackId}; @@ -47,6 +48,12 @@ pub fn create_commit( let mut guard = project.exclusive_worktree_access(); let snapshot_tree = ctx.project().prepare_snapshot(guard.read_permission()); let result = vbranch::commit(&ctx, stack_id, message, ownership, run_hooks).map_err(Into::into); + + if run_hooks && result.is_err() { + // If commit hooks fail then files will still be staged. + staging::unstage_all(&ctx)? + } + let _ = snapshot_tree.and_then(|snapshot_tree| { ctx.project().snapshot_commit_creation( snapshot_tree, diff --git a/crates/gitbutler-branch-actions/src/lib.rs b/crates/gitbutler-branch-actions/src/lib.rs index 624b30df04..f0dedfb240 100644 --- a/crates/gitbutler-branch-actions/src/lib.rs +++ b/crates/gitbutler-branch-actions/src/lib.rs @@ -57,7 +57,7 @@ mod gravatar; mod status; use gitbutler_stack::VirtualBranchesHandle; pub use status::get_applied_status; -trait VirtualBranchesExt { +pub trait VirtualBranchesExt { fn virtual_branches(&self) -> VirtualBranchesHandle; } @@ -79,4 +79,5 @@ pub use branch::{ pub use integration::GITBUTLER_WORKSPACE_COMMIT_TITLE; +pub mod ownership; pub mod stack; diff --git a/crates/gitbutler-branch-actions/src/ownership.rs b/crates/gitbutler-branch-actions/src/ownership.rs new file mode 100644 index 0000000000..7782c335be --- /dev/null +++ b/crates/gitbutler-branch-actions/src/ownership.rs @@ -0,0 +1,36 @@ +use std::path::PathBuf; + +use anyhow::{anyhow, Result}; +use gitbutler_diff::{DiffByPathMap, GitHunk}; +use gitbutler_stack::BranchOwnershipClaims; +use itertools::Itertools; + +pub fn filter_hunks_by_ownership( + diffs: &DiffByPathMap, + ownership: &BranchOwnershipClaims, +) -> Result)>> { + ownership + .claims + .iter() + .map(|claim| { + if let Some(diff) = diffs.get(&claim.file_path) { + let hunks = claim + .hunks + .iter() + .filter_map(|claimed_hunk| { + diff.hunks + .iter() + .find(|diff_hunk| { + claimed_hunk.start == diff_hunk.new_start + && claimed_hunk.end == diff_hunk.new_start + diff_hunk.new_lines + }) + .cloned() + }) + .collect_vec(); + Ok((claim.file_path.clone(), hunks)) + } else { + Err(anyhow!("Claim not found in workspace diff")) + } + }) + .collect::)>>>() +} diff --git a/crates/gitbutler-branch-actions/src/virtual.rs b/crates/gitbutler-branch-actions/src/virtual.rs index 16b1d22b5e..1db6e505bb 100644 --- a/crates/gitbutler-branch-actions/src/virtual.rs +++ b/crates/gitbutler-branch-actions/src/virtual.rs @@ -5,6 +5,7 @@ use crate::{ file::VirtualBranchFile, hunk::VirtualBranchHunk, integration::get_workspace_head, + ownership::filter_hunks_by_ownership, remote::branch_to_remote_branch, stack::stack_series, status::{get_applied_status, get_applied_status_cached}, @@ -12,7 +13,6 @@ use crate::{ }; use anyhow::{anyhow, bail, Context, Result}; use bstr::{BString, ByteSlice}; -use git2_hooks::HookResult; use gitbutler_branch::BranchUpdateRequest; use gitbutler_branch::{dedup, dedup_fmt}; use gitbutler_cherry_pick::RepositoryExt as _; @@ -28,9 +28,10 @@ use gitbutler_oxidize::{ use gitbutler_project::access::WorktreeWritePermission; use gitbutler_reference::{normalize_branch_name, Refname, RemoteRefname}; use gitbutler_repo::{ + hooks, logging::{LogUntil, RepositoryExt as _}, rebase::{cherry_rebase, cherry_rebase_group}, - RepositoryExt, + staging, RepositoryExt, }; use gitbutler_repo_actions::RepoActionsExt; use gitbutler_stack::{ @@ -40,7 +41,6 @@ use gitbutler_stack::{ use gitbutler_time::time::now_since_unix_epoch_ms; use itertools::Itertools; use serde::Serialize; -use std::borrow::Cow; use std::{collections::HashMap, path::PathBuf, vec}; use tracing::instrument; @@ -732,98 +732,47 @@ pub fn commit( ownership: Option<&BranchOwnershipClaims>, run_hooks: bool, ) -> Result { - let mut message_buffer = message.to_owned(); + let diffs = gitbutler_diff::workdir(ctx.repo(), get_workspace_head(ctx)?)?; - fn join_output<'a>(stdout: &'a str, stderr: &'a str) -> Cow<'a, str> { - let stdout = stdout.trim(); - if stdout.is_empty() { - stderr.trim().into() - } else { - stdout.into() - } + if ownership.is_none() { + // If we are committing everything owned by a branch then we + // need to make sure we have first updated ownerships. + get_applied_status_cached(ctx, None, &diffs)?; } - if run_hooks { - let hook_result = - git2_hooks::hooks_commit_msg(ctx.repo(), Some(&["../.husky"]), &mut message_buffer) - .context("failed to run hook") - .context(Code::CommitHookFailed)?; - - if let HookResult::RunNotSuccessful { stdout, stderr, .. } = &hook_result { - return Err( - anyhow!("commit-msg hook rejected: {}", join_output(stdout, stderr)) - .context(Code::CommitHookFailed), - ); - } + let mut stack = ctx.project().virtual_branches().get_stack(stack_id)?; + let ownership = ownership + .map::, _>(Ok) + .unwrap_or_else(|| Ok(&stack.ownership))?; - let hook_result = git2_hooks::hooks_pre_commit(ctx.repo(), Some(&["../.husky"])) - .context("failed to run hook") - .context(Code::CommitHookFailed)?; + let selected_files = filter_hunks_by_ownership(&diffs, ownership)?; - if let HookResult::RunNotSuccessful { stdout, stderr, .. } = &hook_result { - return Err( - anyhow!("commit hook rejected: {}", join_output(stdout, stderr)) - .context(Code::CommitHookFailed), - ); + let final_message = if run_hooks { + hooks::message(ctx, message.to_owned())? + } else { + message.to_owned() + }; + + if run_hooks { + staging::stage_files(ctx, &selected_files)?; + let result = hooks::pre_commit(ctx); + if result.is_err() { + staging::unstage_all(ctx)?; + result?; } } - let message = &message_buffer; - - // get the files to commit - let diffs = gitbutler_diff::workdir(ctx.repo(), get_workspace_head(ctx)?)?; - let statuses = get_applied_status_cached(ctx, None, &diffs) - .context("failed to get status by branch")? - .branches; - - let (ref mut branch, files) = statuses - .into_iter() - .find(|(stack, _)| stack.id == stack_id) - .with_context(|| format!("stack {stack_id} not found"))?; - update_conflict_markers(ctx, &diffs).context(Code::CommitMergeConflictFailure)?; ctx.assure_unconflicted() .context(Code::CommitMergeConflictFailure)?; - let tree_oid = if let Some(ownership) = ownership { - let files = files.into_iter().filter_map(|file| { - let hunks = file - .hunks - .into_iter() - .filter(|hunk| { - let hunk: GitHunk = hunk.clone().into(); - ownership - .claims - .iter() - .find(|f| f.file_path.eq(&file.path)) - .map_or(false, |f| { - f.hunks.iter().any(|h| { - h.start == hunk.new_start - && h.end == hunk.new_start + hunk.new_lines - }) - }) - }) - .collect::>(); - if hunks.is_empty() { - None - } else { - Some((file.path, hunks)) - } - }); - gitbutler_diff::write::hunks_onto_commit(ctx, branch.head(), files)? - } else { - let files = files - .into_iter() - .map(|file| (file.path, file.hunks)) - .collect::)>>(); - gitbutler_diff::write::hunks_onto_commit(ctx, branch.head(), files)? - }; - let git_repository = ctx.repo(); let parent_commit = git_repository - .find_commit(branch.head()) - .context(format!("failed to find commit {:?}", branch.head()))?; + .find_commit(stack.head()) + .context(format!("failed to find commit {:?}", stack.head()))?; + + let tree_oid = gitbutler_diff::write::hunks_onto_commit(ctx, stack.head(), selected_files)?; let tree = git_repository .find_tree(tree_oid) .context(format!("failed to find tree {:?}", tree_oid))?; @@ -838,23 +787,26 @@ pub fn commit( let merge_parent = git_repository .find_commit(merge_parent) .context(format!("failed to find merge parent {:?}", merge_parent))?; - let commit_oid = ctx.commit(message, &tree, &[&parent_commit, &merge_parent], None)?; + let commit_oid = ctx.commit( + &final_message, + &tree, + &[&parent_commit, &merge_parent], + None, + )?; conflicts::clear(ctx) .context("failed to clear conflicts") .context(Code::CommitMergeConflictFailure)?; commit_oid } - None => ctx.commit(message, &tree, &[&parent_commit], None)?, + None => ctx.commit(&final_message, &tree, &[&parent_commit], None)?, }; if run_hooks { - git2_hooks::hooks_post_commit(ctx.repo(), Some(&["../.husky"])) - .context("failed to run hook") - .context(Code::CommitHookFailed)?; + hooks::post_commit(ctx)?; } let vb_state = ctx.project().virtual_branches(); - branch.set_stack_head(ctx, commit_oid, Some(tree_oid))?; + stack.set_stack_head(ctx, commit_oid, Some(tree_oid))?; crate::integration::update_workspace_commit(&vb_state, ctx) .context("failed to update gitbutler workspace")?; diff --git a/crates/gitbutler-diff/src/diff.rs b/crates/gitbutler-diff/src/diff.rs index be88408b3e..5fa0f6791f 100644 --- a/crates/gitbutler-diff/src/diff.rs +++ b/crates/gitbutler-diff/src/diff.rs @@ -16,6 +16,8 @@ pub type DiffByPathMap = HashMap; pub enum ChangeType { /// Entry does not exist in old version Added, + /// Entry is untracked item in workdir + Untracked, /// Entry does not exist in new version Deleted, /// Entry content changed between old and new @@ -26,7 +28,8 @@ impl From for ChangeType { use git2::Delta as D; use ChangeType as C; match v { - D::Untracked | D::Added => C::Added, + D::Added => C::Added, + D::Untracked => C::Untracked, D::Modified | D::Unmodified | D::Renamed @@ -609,6 +612,7 @@ fn reverse_lines( pub fn reverse_hunk(hunk: &GitHunk) -> Option { let new_change_type = match hunk.change_type { ChangeType::Added => ChangeType::Deleted, + ChangeType::Untracked => ChangeType::Deleted, ChangeType::Deleted => ChangeType::Added, ChangeType::Modified => ChangeType::Modified, }; @@ -635,6 +639,7 @@ pub fn reverse_hunk_lines( lines: Vec<(Option, Option)>, ) -> Option { let new_change_type = match hunk.change_type { + ChangeType::Untracked => ChangeType::Deleted, ChangeType::Added => ChangeType::Deleted, ChangeType::Deleted => ChangeType::Added, ChangeType::Modified => ChangeType::Modified, diff --git a/crates/gitbutler-diff/src/write.rs b/crates/gitbutler-diff/src/write.rs index 7d3b804e19..468627f72b 100644 --- a/crates/gitbutler-diff/src/write.rs +++ b/crates/gitbutler-diff/src/write.rs @@ -195,7 +195,10 @@ where // upsert into the builder builder.upsert(rel_path, new_blob_oid, filemode); } else if !full_path_exists - && discard_hunk.map_or(false, |hunk| hunk.change_type == crate::ChangeType::Added) + && discard_hunk.map_or(false, |hunk| { + hunk.change_type == crate::ChangeType::Added + || hunk.change_type == crate::ChangeType::Untracked + }) { // File was deleted but now that hunk is being discarded with an inversed hunk let mut all_diffs = BString::default(); diff --git a/crates/gitbutler-repo/Cargo.toml b/crates/gitbutler-repo/Cargo.toml index a49e109a3d..e0fe4e08e8 100644 --- a/crates/gitbutler-repo/Cargo.toml +++ b/crates/gitbutler-repo/Cargo.toml @@ -8,6 +8,7 @@ autotests = false [dependencies] git2.workspace = true +git2-hooks = "0.4" gix = { workspace = true, features = ["merge", "status", "tree-editor"] } anyhow = "1.0.95" bstr.workspace = true @@ -25,6 +26,7 @@ gitbutler-commit.workspace = true gitbutler-url.workspace = true gitbutler-cherry-pick.workspace = true gitbutler-oxidize.workspace = true +gitbutler-diff.workspace = true uuid.workspace = true itertools = "0.14" toml.workspace = true diff --git a/crates/gitbutler-repo/src/hooks.rs b/crates/gitbutler-repo/src/hooks.rs new file mode 100644 index 0000000000..174172263f --- /dev/null +++ b/crates/gitbutler-repo/src/hooks.rs @@ -0,0 +1,50 @@ +use std::borrow::Cow; + +use anyhow::{anyhow, Context, Result}; +use git2_hooks::HookResult; +use gitbutler_command_context::CommandContext; +use gitbutler_error::error::Code; + +pub fn message(ctx: &CommandContext, mut message: String) -> Result { + let hook_result = git2_hooks::hooks_commit_msg(ctx.repo(), Some(&["../.husky"]), &mut message) + .context("failed to run hook") + .context(Code::CommitHookFailed)?; + + if let HookResult::RunNotSuccessful { stdout, stderr, .. } = &hook_result { + return Err( + anyhow!("commit-msg hook rejected: {}", join_output(stdout, stderr)) + .context(Code::CommitHookFailed), + ); + } + Ok(message) +} + +pub fn pre_commit(ctx: &CommandContext) -> Result<()> { + let hook_result = git2_hooks::hooks_pre_commit(ctx.repo(), Some(&["../.husky"])) + .context("failed to run hook") + .context(Code::CommitHookFailed)?; + + if let HookResult::RunNotSuccessful { stdout, stderr, .. } = &hook_result { + return Err( + anyhow!("commit hook rejected: {}", join_output(stdout, stderr)) + .context(Code::CommitHookFailed), + ); + } + Ok(()) +} + +pub fn post_commit(ctx: &CommandContext) -> Result<()> { + git2_hooks::hooks_post_commit(ctx.repo(), Some(&["../.husky"])) + .context("failed to run hook") + .context(Code::CommitHookFailed)?; + Ok(()) +} + +fn join_output<'a>(stdout: &'a str, stderr: &'a str) -> Cow<'a, str> { + let stdout = stdout.trim(); + if stdout.is_empty() { + stderr.trim().into() + } else { + stdout.into() + } +} diff --git a/crates/gitbutler-repo/src/lib.rs b/crates/gitbutler-repo/src/lib.rs index 3825920e5a..c48464d1e0 100644 --- a/crates/gitbutler-repo/src/lib.rs +++ b/crates/gitbutler-repo/src/lib.rs @@ -10,7 +10,9 @@ pub use repository_ext::RepositoryExt; pub mod credentials; mod config; +pub mod hooks; mod remote; +pub mod staging; pub use config::Config; diff --git a/crates/gitbutler-repo/src/staging.rs b/crates/gitbutler-repo/src/staging.rs new file mode 100644 index 0000000000..9fc2a1ac19 --- /dev/null +++ b/crates/gitbutler-repo/src/staging.rs @@ -0,0 +1,67 @@ +use std::path::PathBuf; + +use anyhow::Result; +use git2::{ApplyLocation, ApplyOptions, Repository, ResetType}; +use gitbutler_command_context::CommandContext; +use gitbutler_diff::{ChangeType, GitHunk}; + +pub fn stage_file(ctx: &CommandContext, path: &PathBuf, hunks: &Vec) -> Result<()> { + let repo = ctx.repo(); + let mut index = repo.index()?; + if hunks.iter().any(|h| h.change_type == ChangeType::Untracked) { + index.add_path(path)?; + index.write()?; + return Ok(()); + } + + let mut apply_opts = ApplyOptions::new(); + apply_opts.hunk_callback(|cb_hunk| { + cb_hunk.map_or(false, |cb_hunk| { + for hunk in hunks { + if hunk.new_start == cb_hunk.new_start() + && hunk.new_start + hunk.new_lines == cb_hunk.new_start() + cb_hunk.new_lines() + { + return true; + } + } + false + }) + }); + + let diff = diff_workdir_to_index(repo, path)?; + repo.apply(&diff, ApplyLocation::Index, Some(&mut apply_opts))?; + Ok(()) +} + +pub fn stage_files( + ctx: &CommandContext, + files_to_stage: &Vec<(PathBuf, Vec)>, +) -> Result<()> { + for (path_to_stage, hunks_to_stage) in files_to_stage { + stage_file(ctx, path_to_stage, hunks_to_stage)?; + } + Ok(()) +} + +pub fn unstage_all(ctx: &CommandContext) -> Result<()> { + let repo = ctx.repo(); + // Get the HEAD commit (current commit) + let head_commit = repo.head()?.peel_to_commit()?; + // Reset the index to match the HEAD commit + repo.reset(head_commit.as_object(), ResetType::Mixed, None)?; + Ok(()) +} + +fn diff_workdir_to_index<'a>(repo: &'a Repository, path: &PathBuf) -> Result> { + let index = repo.index()?; + let mut diff_opts = git2::DiffOptions::new(); + diff_opts + .recurse_untracked_dirs(true) + .include_untracked(true) + .show_binary(true) + .show_untracked_content(true) + .ignore_submodules(true) + .context_lines(3) + .pathspec(path); + Ok(repo.diff_index_to_workdir(Some(&index), Some(&mut diff_opts))?) +} diff --git a/crates/gitbutler-tauri/Cargo.toml b/crates/gitbutler-tauri/Cargo.toml index 18308194ce..260a9ab48e 100644 --- a/crates/gitbutler-tauri/Cargo.toml +++ b/crates/gitbutler-tauri/Cargo.toml @@ -85,6 +85,7 @@ gitbutler-forge.workspace = true gitbutler-settings.workspace = true open = "5" url = "2.5.4" +itertools = "0.14" [lints.clippy] all = "deny" diff --git a/crates/gitbutler-tauri/src/main.rs b/crates/gitbutler-tauri/src/main.rs index 88795773a7..d0f5bb1cfd 100644 --- a/crates/gitbutler-tauri/src/main.rs +++ b/crates/gitbutler-tauri/src/main.rs @@ -174,6 +174,7 @@ fn main() { repo::commands::get_uncommited_files, repo::commands::get_commit_file, repo::commands::get_workspace_file, + repo::commands::run_hooks, virtual_branches::commands::list_virtual_branches, virtual_branches::commands::create_virtual_branch, virtual_branches::commands::delete_local_branch, diff --git a/crates/gitbutler-tauri/src/repo.rs b/crates/gitbutler-tauri/src/repo.rs index 3163cfb474..c6cbcc14b5 100644 --- a/crates/gitbutler-tauri/src/repo.rs +++ b/crates/gitbutler-tauri/src/repo.rs @@ -1,10 +1,13 @@ pub mod commands { use crate::error::{Error, UnmarkedError}; - use anyhow::Result; + use anyhow::{Context, Result}; + use gitbutler_branch_actions::ownership::filter_hunks_by_ownership; use gitbutler_branch_actions::RemoteBranchFile; + use gitbutler_command_context::CommandContext; use gitbutler_project as projects; use gitbutler_project::ProjectId; - use gitbutler_repo::{FileInfo, RepoCommands}; + use gitbutler_repo::{hooks, staging, FileInfo, RepoCommands}; + use gitbutler_stack::BranchOwnershipClaims; use std::path::Path; use std::sync::atomic::AtomicBool; use tauri::State; @@ -92,4 +95,32 @@ pub mod commands { let project = projects.get(project_id)?; Ok(project.read_file_from_workspace(relative_path)?) } + + #[tauri::command(async)] + #[instrument(skip(projects))] + pub fn run_hooks( + projects: State<'_, projects::Controller>, + project_id: ProjectId, + ownership: BranchOwnershipClaims, + ) -> Result<(), Error> { + let project = projects.get(project_id)?; + let ctx = CommandContext::open(&project)?; + let repo = ctx.repo(); + let diffs = gitbutler_diff::workdir( + ctx.repo(), + repo.head() + .context("no head")? + .peel_to_commit() + .context("no commit")? + .id(), + )?; + let selected_files = filter_hunks_by_ownership(&diffs, &ownership)?; + staging::stage_files(&ctx, &selected_files)?; + let result = hooks::pre_commit(&ctx); + if result.is_err() { + staging::unstage_all(&ctx)?; + result?; + } + Ok(()) + } }