Skip to content

Commit b7d5f16

Browse files
committed
Refactor git commit hooks
- the typescript side drives the hook runs - some very basic tests
1 parent 5a22bc8 commit b7d5f16

File tree

19 files changed

+478
-41
lines changed

19 files changed

+478
-41
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

+28
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';
@@ -19,6 +20,7 @@
1920
import { BranchStack, DetailedCommit, Commit } from '$lib/vbranches/types';
2021
import { getContext, getContextStore } from '@gitbutler/shared/context';
2122
import Checkbox from '@gitbutler/ui/Checkbox.svelte';
23+
import Icon from '@gitbutler/ui/Icon.svelte';
2224
import Textarea from '@gitbutler/ui/Textarea.svelte';
2325
import Tooltip from '@gitbutler/ui/Tooltip.svelte';
2426
import { isWhiteSpaceString } from '@gitbutler/ui/utils/string';
@@ -47,12 +49,14 @@
4749
const stack = getContextStore(BranchStack);
4850
const project = getContext(Project);
4951
const promptService = getContext(PromptService);
52+
const hooksService = getContext(HooksService);
5053
5154
const aiGenEnabled = projectAiGenEnabled(project.id);
5255
const commitGenerationExtraConcise = projectCommitGenerationExtraConcise(project.id);
5356
const commitGenerationUseEmojis = projectCommitGenerationUseEmojis(project.id);
5457
5558
let aiLoading = $state(false);
59+
let hookRunning = $state(false);
5660
let aiConfigurationValid = $state(false);
5761
5862
let titleTextArea: HTMLTextAreaElement | undefined = $state();
@@ -134,6 +138,22 @@
134138
aiLoading = false;
135139
}
136140
141+
async function runMessageHook() {
142+
hookRunning = true;
143+
try {
144+
const hook_result = await hooksService.message(project.id, commitMessage);
145+
if (hook_result.status === 'message') {
146+
commitMessage = hook_result.message;
147+
} else if (hook_result.status === 'failure') {
148+
showError('Message hook failed', hook_result.error);
149+
}
150+
} catch (err: unknown) {
151+
showError('Message hook failed', err);
152+
} finally {
153+
hookRunning = false;
154+
}
155+
}
156+
137157
onMount(async () => {
138158
aiConfigurationValid = await aiService.validateConfiguration();
139159
});
@@ -237,6 +257,7 @@
237257
}}
238258
onblur={() => {
239259
isTitleFocused = false;
260+
runMessageHook();
240261
}}
241262
oninput={(e: Event & { currentTarget: EventTarget & HTMLTextAreaElement }) => {
242263
const target = e.currentTarget;
@@ -263,6 +284,7 @@
263284
}}
264285
onblur={() => {
265286
isDescriptionFocused = false;
287+
runMessageHook();
266288
}}
267289
oninput={(e: Event & { currentTarget: EventTarget & HTMLTextAreaElement }) => {
268290
const target = e.currentTarget;
@@ -288,6 +310,9 @@
288310
: undefined}
289311
>
290312
<div class="commit-box__texarea-actions" class:commit-box-actions_expanded={isExpanded}>
313+
{#if hookRunning}
314+
<Icon name="spinner" opacity={0.4} />
315+
{/if}
291316
<DropDownButton
292317
style="ghost"
293318
outline
@@ -361,6 +386,9 @@
361386
}
362387
363388
.commit-box__texarea-actions {
389+
display: flex;
390+
align-items: center;
391+
gap: 10px;
364392
position: absolute;
365393
right: 12px;
366394
bottom: 12px;
+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-diff/src/diff.rs

+6-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ pub type DiffByPathMap = HashMap<PathBuf, FileDiff>;
1616
pub enum ChangeType {
1717
/// Entry does not exist in old version
1818
Added,
19+
/// Entry is untracked item in workdir
20+
Untracked,
1921
/// Entry does not exist in new version
2022
Deleted,
2123
/// Entry content changed between old and new
@@ -26,7 +28,8 @@ impl From<git2::Delta> for ChangeType {
2628
use git2::Delta as D;
2729
use ChangeType as C;
2830
match v {
29-
D::Untracked | D::Added => C::Added,
31+
D::Added => C::Added,
32+
D::Untracked => C::Untracked,
3033
D::Modified
3134
| D::Unmodified
3235
| D::Renamed
@@ -609,6 +612,7 @@ fn reverse_lines(
609612
pub fn reverse_hunk(hunk: &GitHunk) -> Option<GitHunk> {
610613
let new_change_type = match hunk.change_type {
611614
ChangeType::Added => ChangeType::Deleted,
615+
ChangeType::Untracked => ChangeType::Deleted,
612616
ChangeType::Deleted => ChangeType::Added,
613617
ChangeType::Modified => ChangeType::Modified,
614618
};
@@ -635,6 +639,7 @@ pub fn reverse_hunk_lines(
635639
lines: Vec<(Option<u32>, Option<u32>)>,
636640
) -> Option<GitHunk> {
637641
let new_change_type = match hunk.change_type {
642+
ChangeType::Untracked => ChangeType::Deleted,
638643
ChangeType::Added => ChangeType::Deleted,
639644
ChangeType::Deleted => ChangeType::Added,
640645
ChangeType::Modified => ChangeType::Modified,

crates/gitbutler-diff/src/write.rs

+4-1
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,10 @@ where
195195
// upsert into the builder
196196
builder.upsert(rel_path, new_blob_oid, filemode);
197197
} else if !full_path_exists
198-
&& discard_hunk.map_or(false, |hunk| hunk.change_type == crate::ChangeType::Added)
198+
&& discard_hunk.map_or(false, |hunk| {
199+
hunk.change_type == crate::ChangeType::Added
200+
|| hunk.change_type == crate::ChangeType::Untracked
201+
})
199202
{
200203
// File was deleted but now that hunk is being discarded with an inversed hunk
201204
let mut all_diffs = BString::default();

crates/gitbutler-error/src/error.rs

-2
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,6 @@ pub enum Code {
131131
ProjectGitAuth,
132132
DefaultTargetNotFound,
133133
CommitSigningFailed,
134-
CommitHookFailed,
135134
CommitMergeConflictFailure,
136135
ProjectMissing,
137136
AuthorMissing,
@@ -145,7 +144,6 @@ impl std::fmt::Display for Code {
145144
Code::ProjectGitAuth => "errors.projects.git.auth",
146145
Code::DefaultTargetNotFound => "errors.projects.default_target.not_found",
147146
Code::CommitSigningFailed => "errors.commit.signing_failed",
148-
Code::CommitHookFailed => "errors.commit.hook_failed",
149147
Code::CommitMergeConflictFailure => "errors.commit.merge_conflict_failure",
150148
Code::AuthorMissing => "errors.git.author_missing",
151149
Code::ProjectMissing => "errors.projects.missing",

0 commit comments

Comments
 (0)