Skip to content

Commit 9fbe393

Browse files
committed
Refactor git commit hooks
- the typescript side drives the hook runs - some very basic tests
1 parent 06ebdc4 commit 9fbe393

File tree

22 files changed

+489
-46
lines changed

22 files changed

+489
-46
lines changed

Cargo.lock

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src/lib/commit/CommitDialog.svelte

+42-11
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
<script lang="ts">
22
import CommitMessageInput from './CommitMessageInput.svelte';
3+
import { PostHogWrapper } from '$lib/analytics/posthog';
34
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
45
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
56
import { persistedCommitMessage } from '$lib/config/config';
67
import { cloudCommunicationFunctionality } from '$lib/config/uiFeatureFlags';
78
import { SyncedSnapshotService } from '$lib/history/syncedSnapshotService';
9+
import { HooksService } from '$lib/hooks/hooksService';
10+
import { showError } from '$lib/notifications/toasts';
811
import DropDownButton from '$lib/shared/DropDownButton.svelte';
912
import { intersectionObserver } from '$lib/utils/intersectionObserver';
1013
import { BranchController } from '$lib/vbranches/branchController';
@@ -25,30 +28,63 @@
2528
const { projectId, expanded, hasSectionsAfter }: Props = $props();
2629
2730
const branchController = getContext(BranchController);
31+
const hooksService = getContext(HooksService);
32+
const posthog = getContext(PostHogWrapper);
2833
const syncedSnapshotService = getContext(SyncedSnapshotService);
2934
const canTakeSnapshot = syncedSnapshotService.canTakeSnapshot;
3035
const selectedOwnership = getContextStore(SelectedOwnership);
3136
const stack = getContextStore(BranchStack);
3237
const commitMessage = persistedCommitMessage(projectId, $stack.id);
38+
const canShowCommitAndPublish = $derived($cloudCommunicationFunctionality && $canTakeSnapshot);
3339
3440
let commitMessageInput = $state<CommitMessageInput>();
3541
let isCommitting = $state(false);
3642
let commitMessageValid = $state(false);
3743
let isInViewport = $state(false);
3844
45+
let commitAndPublish = $state(false);
46+
let commitButton = $state<DropDownButton>();
47+
3948
async function commit() {
40-
const message = $commitMessage;
4149
isCommitting = true;
42-
try {
43-
await branchController.commitBranch($stack.id, message.trim(), $selectedOwnership.toString());
44-
$commitMessage = '';
50+
const message = $commitMessage;
51+
const ownership = $selectedOwnership.toString();
4552
46-
if (commitAndPublish) {
47-
syncedSnapshotService.takeSyncedSnapshot($stack.id);
53+
try {
54+
const preCommitHook = await hooksService.preCommit(projectId, ownership);
55+
if (preCommitHook.status === 'failure') {
56+
showError('Pre-commit hook failed', preCommitHook.error);
57+
return; // Abort commit if hook failed.
4858
}
59+
await branchController.commit($stack.id, message.trim(), ownership);
60+
} catch (err: unknown) {
61+
showError('Failed to commit changes', err);
62+
posthog.capture('Commit Failed', { error: err });
63+
return;
4964
} finally {
5065
isCommitting = false;
5166
}
67+
68+
// Run both without awaiting unless commit failed.
69+
runPostCommitActions();
70+
runPostCommitHook();
71+
}
72+
73+
async function runPostCommitActions() {
74+
// Clear the commit message editor.
75+
commitMessage.set('');
76+
77+
// Publishing a snapshot seems to imply posting a bleep.
78+
if (commitAndPublish) {
79+
await syncedSnapshotService.takeSyncedSnapshot($stack.id);
80+
}
81+
}
82+
83+
async function runPostCommitHook() {
84+
const postCommitHook = await hooksService.postCommit(projectId);
85+
if (postCommitHook.status === 'failure') {
86+
showError('Post-commit hook failed', postCommitHook.error);
87+
}
5288
}
5389
5490
function close() {
@@ -60,11 +96,6 @@
6096
await tick();
6197
commitMessageInput?.focus();
6298
}
63-
64-
const canShowCommitAndPublish = $derived($cloudCommunicationFunctionality && $canTakeSnapshot);
65-
66-
let commitAndPublish = $state(false);
67-
let commitButton = $state<DropDownButton>();
6899
</script>
69100

70101
<div

apps/desktop/src/lib/commit/CommitMessageInput.svelte

+21
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
projectCommitGenerationExtraConcise,
1010
projectCommitGenerationUseEmojis
1111
} from '$lib/config/config';
12+
import { HooksService } from '$lib/hooks/hooksService';
1213
import { showError } from '$lib/notifications/toasts';
1314
import { isFailure } from '$lib/result';
1415
import DropDownButton from '$lib/shared/DropDownButton.svelte';
@@ -47,12 +48,14 @@
4748
const stack = getContextStore(BranchStack);
4849
const project = getContext(Project);
4950
const promptService = getContext(PromptService);
51+
const hooksService = getContext(HooksService);
5052
5153
const aiGenEnabled = projectAiGenEnabled(project.id);
5254
const commitGenerationExtraConcise = projectCommitGenerationExtraConcise(project.id);
5355
const commitGenerationUseEmojis = projectCommitGenerationUseEmojis(project.id);
5456
5557
let aiLoading = $state(false);
58+
let hookRunning = $state(false);
5659
let aiConfigurationValid = $state(false);
5760
5861
let titleTextArea: HTMLTextAreaElement | undefined = $state();
@@ -134,6 +137,22 @@
134137
aiLoading = false;
135138
}
136139
140+
async function runMessageHook() {
141+
hookRunning = true;
142+
try {
143+
const hook_result = await hooksService.message(project.id, commitMessage);
144+
if (hook_result.status === 'message') {
145+
commitMessage = hook_result.message;
146+
} else if (hook_result.status === 'failure') {
147+
showError('Message hook failed', hook_result.error);
148+
}
149+
} catch (err: unknown) {
150+
showError('Message hook failed', err);
151+
} finally {
152+
hookRunning = false;
153+
}
154+
}
155+
137156
onMount(async () => {
138157
aiConfigurationValid = await aiService.validateConfiguration();
139158
});
@@ -237,6 +256,7 @@
237256
}}
238257
onblur={() => {
239258
isTitleFocused = false;
259+
runMessageHook();
240260
}}
241261
oninput={(e: Event & { currentTarget: EventTarget & HTMLTextAreaElement }) => {
242262
const target = e.currentTarget;
@@ -263,6 +283,7 @@
263283
}}
264284
onblur={() => {
265285
isDescriptionFocused = false;
286+
runMessageHook();
266287
}}
267288
oninput={(e: Event & { currentTarget: EventTarget & HTMLTextAreaElement }) => {
268289
const target = e.currentTarget;
+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { Tauri } from '$lib/backend/tauri';
2+
3+
export type HookStatus =
4+
| {
5+
status: 'success';
6+
}
7+
| {
8+
status: 'message';
9+
message: string;
10+
}
11+
| {
12+
status: 'notfound';
13+
}
14+
| {
15+
status: 'failure';
16+
error: string;
17+
};
18+
19+
export class HooksService {
20+
constructor(private tauri: Tauri) {}
21+
22+
async preCommit(projectId: string, ownership: string | undefined = undefined) {
23+
return await this.tauri.invoke<HookStatus>('pre_commit_hook', {
24+
projectId,
25+
ownership
26+
});
27+
}
28+
29+
async postCommit(projectId: string) {
30+
return await this.tauri.invoke<HookStatus>('post_commit_hook', {
31+
projectId
32+
});
33+
}
34+
35+
async message(projectId: string, message: string) {
36+
return await this.tauri.invoke<HookStatus>('message_hook', {
37+
projectId,
38+
message
39+
});
40+
}
41+
}

apps/desktop/src/lib/vbranches/branchController.ts

+5-23
Original file line numberDiff line numberDiff line change
@@ -52,32 +52,14 @@ export class BranchController {
5252
}
5353
}
5454

55-
async runHooks(stackId: string, ownership: string) {
56-
await invoke<void>('run_hooks', {
55+
async commit(branchId: string, message: string, ownership: string | undefined = undefined) {
56+
await invoke<void>('commit_virtual_branch', {
5757
projectId: this.projectId,
58-
stackId,
58+
branch: branchId,
59+
message,
5960
ownership
6061
});
61-
}
62-
63-
async commitBranch(branchId: string, message: string, ownership: string | undefined = undefined) {
64-
try {
65-
await invoke<void>('commit_virtual_branch', {
66-
projectId: this.projectId,
67-
branch: branchId,
68-
message,
69-
ownership
70-
});
71-
this.posthog.capture('Commit Successful');
72-
} catch (err: any) {
73-
if (err.code === 'errors.commit.signing_failed') {
74-
showSignError(err);
75-
} else {
76-
showError('Failed to commit changes', err);
77-
throw err;
78-
}
79-
this.posthog.capture('Commit Failed', err);
80-
}
62+
this.posthog.capture('Commit Successful');
8163
}
8264

8365
async integrateUpstreamForSeries(

apps/desktop/src/routes/+layout.svelte

+2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
GitHubUserService
3030
} from '$lib/forge/github/githubUserService';
3131
import { octokitFromAccessToken } from '$lib/forge/github/octokit';
32+
import { HooksService } from '$lib/hooks/hooksService';
3233
import ToastController from '$lib/notifications/ToastController.svelte';
3334
import { platformName } from '$lib/platform/platform';
3435
import { DesktopDispatch, DesktopState } from '$lib/redux/store.svelte';
@@ -79,6 +80,7 @@
7980
setContext(OrganizationService, organizationService);
8081
setContext(CloudUserService, cloudUserService);
8182
setContext(CloudProjectService, cloudProjectService);
83+
setContext(HooksService, data.hooksService);
8284
8385
// Setters do not need to be reactive since `data` never updates
8486
setSecretsService(data.secretsService);

apps/desktop/src/routes/+layout.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Tauri } from '$lib/backend/tauri';
1111
import { UpdaterService } from '$lib/backend/updater';
1212
import { loadAppSettings } from '$lib/config/appSettings';
1313
import { FileService } from '$lib/files/fileService';
14+
import { HooksService } from '$lib/hooks/hooksService';
1415
import { RemotesService } from '$lib/remotes/service';
1516
import { RustSecretService } from '$lib/secrets/secretsService';
1617
import { TokenMemoryService } from '$lib/stores/tokenMemoryService';
@@ -62,6 +63,7 @@ export const load: LayoutLoad = async () => {
6263
const lineManagerFactory = new LineManagerFactory();
6364
const stackingLineManagerFactory = new StackingLineManagerFactory();
6465
const fileService = new FileService(tauri);
66+
const hooksService = new HooksService(tauri);
6567

6668
return {
6769
commandService,
@@ -82,6 +84,7 @@ export const load: LayoutLoad = async () => {
8284
secretsService,
8385
posthog,
8486
tauri,
85-
fileService
87+
fileService,
88+
hooksService
8689
};
8790
};

crates/gitbutler-branch-actions/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,5 @@ pub use branch::{
7979

8080
pub use integration::GITBUTLER_WORKSPACE_COMMIT_TITLE;
8181

82+
pub mod ownership;
8283
pub mod stack;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
use std::path::PathBuf;
2+
3+
use anyhow::{anyhow, Result};
4+
use gitbutler_diff::{DiffByPathMap, GitHunk};
5+
use gitbutler_stack::BranchOwnershipClaims;
6+
use itertools::Itertools;
7+
8+
pub fn filter_hunks_by_ownership(
9+
diffs: &DiffByPathMap,
10+
ownership: &BranchOwnershipClaims,
11+
) -> Result<Vec<(PathBuf, Vec<GitHunk>)>> {
12+
ownership
13+
.claims
14+
.iter()
15+
.map(|claim| {
16+
if let Some(diff) = diffs.get(&claim.file_path) {
17+
let hunks = claim
18+
.hunks
19+
.iter()
20+
.filter_map(|claimed_hunk| {
21+
diff.hunks
22+
.iter()
23+
.find(|diff_hunk| {
24+
claimed_hunk.start == diff_hunk.new_start
25+
&& claimed_hunk.end == diff_hunk.new_start + diff_hunk.new_lines
26+
})
27+
.cloned()
28+
})
29+
.collect_vec();
30+
Ok((claim.file_path.clone(), hunks))
31+
} else {
32+
Err(anyhow!("Claim not found in workspace diff"))
33+
}
34+
})
35+
.collect::<Result<Vec<(_, Vec<_>)>>>()
36+
}

crates/gitbutler-branch-actions/src/virtual.rs

+7-5
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ pub(crate) fn reset_files(
257257
) -> Result<()> {
258258
ctx.assure_resolved()?;
259259

260-
let stack = ctx
260+
let branch = ctx
261261
.project()
262262
.virtual_branches()
263263
.list_stacks_in_workspace()
@@ -267,7 +267,7 @@ pub(crate) fn reset_files(
267267
.with_context(|| {
268268
format!("could not find applied branch with id {stack_id} to reset files from")
269269
})?;
270-
let claims: Vec<_> = stack
270+
let claims: Vec<_> = branch
271271
.ownership
272272
.claims
273273
.into_iter()
@@ -729,6 +729,7 @@ pub fn commit(
729729
message: &str,
730730
ownership: Option<&BranchOwnershipClaims>,
731731
) -> Result<git2::Oid> {
732+
let mut stack = ctx.project().virtual_branches().get_stack(stack_id)?;
732733
// get the files to commit
733734
let diffs = gitbutler_diff::workdir(ctx.repo(), get_workspace_head(ctx)?)?;
734735
let statuses = get_applied_status_cached(ctx, None, &diffs)
@@ -781,8 +782,9 @@ pub fn commit(
781782

782783
let git_repository = ctx.repo();
783784
let parent_commit = git_repository
784-
.find_commit(branch.head())
785-
.context(format!("failed to find commit {:?}", branch.head()))?;
785+
.find_commit(stack.head())
786+
.context(format!("failed to find commit {:?}", stack.head()))?;
787+
786788
let tree = git_repository
787789
.find_tree(tree_oid)
788790
.context(format!("failed to find tree {:?}", tree_oid))?;
@@ -807,7 +809,7 @@ pub fn commit(
807809
};
808810

809811
let vb_state = ctx.project().virtual_branches();
810-
branch.set_stack_head(ctx, commit_oid, Some(tree_oid))?;
812+
stack.set_stack_head(ctx, commit_oid, Some(tree_oid))?;
811813

812814
crate::integration::update_workspace_commit(&vb_state, ctx)
813815
.context("failed to update gitbutler workspace")?;

0 commit comments

Comments
 (0)