Skip to content

Commit c3e0637

Browse files
authored
tools: implement v git-fmt-hook, v git-fmt-hook install, v git-fmt-hook remove + tests for them (#25855)
1 parent 6aa81fa commit c3e0637

File tree

4 files changed

+331
-4
lines changed

4 files changed

+331
-4
lines changed

.github/workflows/tools_ci.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ jobs:
3535
steps:
3636
- uses: actions/checkout@v6
3737
- name: Build V
38-
run: make -j4 && ./v -showcc -o v cmd/v && ./v doctor
38+
run: make && ./v -showcc -o v cmd/v && ./v symlink && ./v doctor
3939
- name: Code in cmd/ is formatted
4040
run: ./v fmt -verify cmd/
4141
- name: Check build-tools
@@ -75,7 +75,7 @@ jobs:
7575
steps:
7676
- uses: actions/checkout@v6
7777
- name: Build V
78-
run: make -j4 && ./v -showcc -o v cmd/v && ./v doctor
78+
run: make && ./v -showcc -o v cmd/v && ./v symlink && ./v doctor
7979
- name: Check build-tools
8080
run: ./v -silent -N -W -check build-tools
8181
- name: Test tools
@@ -95,7 +95,7 @@ jobs:
9595
steps:
9696
- uses: actions/checkout@v6
9797
- name: Build V
98-
run: ./make.bat -${{ matrix.cc }} && ./v -o v2.exe cmd/v && ./v2 -showcc -o v.exe cmd/v && ./v doctor
98+
run: ./make.bat -${{ matrix.cc }} && ./v -o v2.exe cmd/v && ./v2 -showcc -o v.exe cmd/v && ./v symlink && ./v doctor
9999
- name: Check build tools
100100
run: ./v build-tools
101101
- name: Test tools
@@ -119,7 +119,7 @@ jobs:
119119
- name: Checkout
120120
uses: actions/checkout@v6
121121
- name: Build V
122-
run: make -j4 && ./v -cg -o v cmd/v
122+
run: make && ./v -cg -o v cmd/v && ./v symlink
123123
- name: Ensure git commands can be used with no prompts on modern Git versions
124124
run: git config --global --add safe.directory /__w/v/v
125125
- name: Verify `v test` works

cmd/tools/vgit-fmt-hook.v

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import os
2+
import crypto.sha256
3+
4+
const vexe = os.getenv_opt('VEXE') or { panic('missing VEXE env variable') }
5+
const vroot = os.to_slash(os.real_path(os.dir(vexe)))
6+
const horiginal = os.to_slash(os.join_path(vroot, 'cmd/tools/git_pre_commit_hook.vsh'))
7+
8+
fn get_hook_target(git_folder string) string {
9+
return os.to_slash(os.join_path(git_folder, 'hooks/pre-commit'))
10+
}
11+
12+
fn main() {
13+
git_folder := find_nearest_top_level_folder_with_a_git_subfolder(os.getwd()) or {
14+
eprintln('This command has to be run inside a Git repository.')
15+
exit(0)
16+
}
17+
os.chdir(git_folder)!
18+
htarget := get_hook_target(git_folder)
19+
cmd := os.args[2] or { 'status' }
20+
match cmd {
21+
'status' {
22+
cmd_status(htarget)
23+
}
24+
'install' {
25+
cmd_install(htarget)
26+
}
27+
'remove' {
28+
cmd_remove(htarget)
29+
}
30+
else {
31+
eprintln('Unknown command `${cmd}`. Known commands are: `status`, `install` or `remove`')
32+
exit(1)
33+
}
34+
}
35+
}
36+
37+
fn cmd_status(htarget string) {
38+
report_status(htarget, true)
39+
}
40+
41+
fn cmd_install(htarget string) {
42+
report_status(htarget, false)
43+
println('> Installing the newest version of ${horiginal} over ${htarget} ...')
44+
os.cp(horiginal, htarget) or { err_exit('failed to copy to ${htarget}') }
45+
println('> Done.')
46+
}
47+
48+
fn cmd_remove(htarget string) {
49+
report_status(htarget, false)
50+
if !os.exists(htarget) {
51+
err_exit('file ${htarget} has been removed already')
52+
}
53+
println('> Removing ${htarget} ...')
54+
os.rm(htarget) or { err_exit('failed to remove ${htarget}') }
55+
println('> Done.')
56+
}
57+
58+
fn report_status(htarget string, show_instructions bool) {
59+
ostat := os.stat(horiginal) or { os.Stat{} }
60+
tstat := os.stat(htarget) or { os.Stat{} }
61+
ohash := hash_file(horiginal) or { '' }
62+
thash := hash_file(htarget) or { '' }
63+
if os.exists(htarget) && os.is_file(htarget) {
64+
println('> CURRENT git repo pre-commit hook: size: ${tstat.size:6} bytes, sha256: ${thash}, ${htarget}')
65+
} else {
66+
println('> CURRENT git repo pre-commit hook: missing ${htarget}')
67+
}
68+
if os.exists(horiginal) && os.is_file(horiginal) {
69+
println('> Main V repo pre-commit hook script: size: ${ostat.size:6} bytes, sha256: ${ohash}, ${horiginal}')
70+
}
71+
if ohash == thash {
72+
println('> Both files are exactly the same.')
73+
if show_instructions {
74+
show_msg_about_removing(htarget)
75+
}
76+
return
77+
}
78+
println('> Files have different hashes.')
79+
if ohash != '' && thash != '' {
80+
existing_content := os.read_file(htarget) or { '' }
81+
if !existing_content.contains('hooks.stopCommitOfNonVfmtedVFiles') {
82+
// both files do exist, but the current git repo hook, is not compatible (an older version of git_pre_commit_hook.vsh):
83+
err_exit('the existing file ${htarget} , does not appear to be a compatible V formatting hook\nYou have to remove it manually')
84+
}
85+
}
86+
if show_instructions {
87+
println("> Use `v git-fmt-hook install` to update the CURRENT repository's pre-commit hook,")
88+
println('> with the newest pre-commit formatting script from the main V repo.')
89+
show_msg_about_removing(htarget)
90+
}
91+
}
92+
93+
fn show_msg_about_removing(htarget string) {
94+
if os.exists(htarget) {
95+
println("> Use `v git-fmt-hook remove` to remove the CURRENT repository's pre-commit hook.")
96+
}
97+
}
98+
99+
fn find_nearest_top_level_folder_with_a_git_subfolder(current string) ?string {
100+
mut cfolder := os.to_slash(os.real_path(current))
101+
for level := 0; level < 255; level++ {
102+
if cfolder == '/' || cfolder == '' {
103+
break
104+
}
105+
git_folder := os.join_path(cfolder, '.git')
106+
if os.is_dir(git_folder) {
107+
return git_folder
108+
}
109+
cfolder = os.dir(cfolder)
110+
}
111+
return none
112+
}
113+
114+
fn hash_file(path string) !string {
115+
fbytes := os.read_bytes(path)!
116+
mut digest256 := sha256.new()
117+
digest256.write(fbytes)!
118+
mut sum256 := digest256.sum([])
119+
return sum256.hex()
120+
}
121+
122+
@[no_return]
123+
fn err_exit(msg string) {
124+
eprintln('> error: ${msg} .')
125+
exit(0) // note: this is important, since the command is ran in `v up` and during `make`
126+
}

cmd/tools/vgit-fmt-hook_test.v

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import os
2+
3+
const vexe = @VEXE
4+
const tfolder = os.to_slash(os.join_path(os.vtmp_dir(), 'fmt_hook_test'))
5+
const unformatted_content = ' fn main() {\nprintln( "hi" )\n println ( 123 )\n }'
6+
const formatted_content = "fn main() {\n\tprintln('hi')\n\tprintln(123)\n}\n"
7+
const hook_file = '.git/hooks/pre-commit'
8+
const foreign_script = '#!/usr/bin/env -S v -raw-vsh-tmp-prefix tmp\nprintln("hello hello")'
9+
10+
const git = os.to_slash(os.find_abs_path_of_executable('git') or {
11+
eprintln('git is needed for this test, skipping...')
12+
exit(0)
13+
})
14+
15+
const v = os.to_slash(os.find_abs_path_of_executable('v') or {
16+
eprintln('v needs to be installed and available on the path for this test, skipping...')
17+
exit(0)
18+
})
19+
20+
fn testsuite_begin() {
21+
unbuffer_stdout()
22+
eprintln('>>>>>> preparing tfolder: ${tfolder}')
23+
full_remove(tfolder) or {}
24+
os.mkdir_all(tfolder) or { panic('> could not create ${tfolder}, err: ${err}') }
25+
os.chdir(tfolder)!
26+
os.write_file('main.v', unformatted_content) or { panic(err) }
27+
assert !os.is_dir('.git')
28+
os.execute_or_exit('git init .')
29+
os.execute_or_exit('git config core.eol lf')
30+
os.execute_or_exit('git config core.autocrlf input')
31+
os.execute_or_exit('git config user.email "me@example.com"')
32+
os.execute_or_exit('git config user.name "Myself"')
33+
assert os.is_dir('.git')
34+
os.execute_or_exit('git add .')
35+
os.execute_or_exit('git commit -m "start testing, initially unformatted"')
36+
os.execute_or_exit('git checkout -b start') // use a known name, instead of master or main or who knows what else ...
37+
assert read_file('main.v') == unformatted_content
38+
// show_git_status()
39+
}
40+
41+
fn testsuite_end() {
42+
reset_to_start_state()
43+
show_git_status()
44+
os.chdir(os.wd_at_startup)!
45+
full_remove(tfolder)!
46+
eprintln('>>>>>> deleted ${tfolder}')
47+
assert true
48+
}
49+
50+
fn test_commit_no_vfmt() {
51+
eprintln('>>>> ${@FN}')
52+
reset_to_start_state()
53+
assert os.execute_or_exit('git checkout -b unformatted').exit_code == 0
54+
append('main.v', '//') or { panic(err) }
55+
assert os.execute_or_exit('git add .').exit_code == 0
56+
assert os.execute_or_exit('git commit -m "unformatted change"').exit_code == 0
57+
assert os.execute_or_exit('git diff start').exit_code == 0
58+
assert read_file('main.v').starts_with(unformatted_content)
59+
}
60+
61+
fn test_run_vfmt_manually() {
62+
eprintln('>>>> ${@FN}')
63+
reset_to_start_state()
64+
assert os.execute_or_exit('git checkout -b formatted').exit_code == 0
65+
os.write_file('README.md', 'some new content') or { panic(err) }
66+
assert os.execute_or_exit('${os.quoted_path(vexe)} fmt -w .').exit_code == 0
67+
assert os.execute_or_exit('git add .').exit_code == 0
68+
assert os.execute_or_exit('git commit -m "formatted change"').exit_code == 0
69+
assert os.execute_or_exit('git diff start').exit_code == 0
70+
assert read_file('main.v') == formatted_content
71+
}
72+
73+
fn test_run_git_fmt_hook() {
74+
eprintln('>>>> ${@FN}')
75+
reset_to_start_state()
76+
res := os.execute_or_exit('${os.quoted_path(vexe)} git-fmt-hook')
77+
assert res.exit_code == 0
78+
assert res.output.contains('> CURRENT git repo pre-commit hook: missing')
79+
assert res.output.contains('> Main V repo pre-commit hook script: size: ')
80+
assert res.output.contains('cmd/tools/git_pre_commit_hook.vsh')
81+
assert res.output.contains('> Files have different hashes.')
82+
assert res.output.contains('> Use `v git-fmt-hook install`')
83+
}
84+
85+
fn test_run_git_fmt_hook_status_explicit() {
86+
eprintln('>>>> ${@FN}')
87+
reset_to_start_state()
88+
res := os.execute_or_exit('${os.quoted_path(vexe)} git-fmt-hook status')
89+
assert res.exit_code == 0
90+
assert res.output.contains('> CURRENT git repo pre-commit hook: missing')
91+
assert res.output.contains('> Main V repo pre-commit hook script: size: ')
92+
assert res.output.contains('cmd/tools/git_pre_commit_hook.vsh')
93+
assert res.output.contains('> Files have different hashes.')
94+
assert res.output.contains('> Use `v git-fmt-hook install`')
95+
}
96+
97+
fn test_run_git_fmt_hook_install() {
98+
eprintln('>>>> ${@FN}')
99+
reset_to_start_state()
100+
os.execute_or_exit('git checkout -b formatting_with_hook')
101+
append('main.v', '\n') or { panic(err) }
102+
assert read_file('main.v').starts_with(unformatted_content)
103+
assert !os.is_file(hook_file)
104+
assert os.execute_or_exit('${os.quoted_path(vexe)} git-fmt-hook install').exit_code == 0
105+
assert os.is_file(hook_file)
106+
res := os.execute_or_exit('${os.quoted_path(vexe)} git-fmt-hook status')
107+
assert res.output.contains('> CURRENT git repo pre-commit hook: size: ')
108+
assert res.output.contains('> Main V repo pre-commit hook script: size: ')
109+
assert res.output.contains('cmd/tools/git_pre_commit_hook.vsh')
110+
assert res.output.contains(hook_file)
111+
assert res.output.contains('> Both files are exactly the same.')
112+
assert !res.output.contains('> Use `v git-fmt-hook install`')
113+
assert res.output.contains('> Use `v git-fmt-hook remove`')
114+
assert !res.output.contains('> Done.'), 'res:\n${res}'
115+
os.execute_or_exit('git add -u')
116+
os.execute_or_exit('git commit -m "this should be formatted"')
117+
assert read_file('main.v') == formatted_content
118+
dres := os.execute_or_exit('git diff start')
119+
// dump(dres)
120+
assert dres.exit_code == 0
121+
assert dres.output.contains('+fn main() {')
122+
assert dres.output.contains("+\tprintln('hi')")
123+
second := os.execute_or_exit('${os.quoted_path(vexe)} git-fmt-hook install')
124+
assert second.exit_code == 0
125+
assert second.output.contains('> Done.'), 'second:\n${second}'
126+
}
127+
128+
fn test_run_git_fmt_hook_remove() {
129+
eprintln('>>>> ${@FN}')
130+
reset_to_start_state()
131+
os.execute_or_exit('git checkout start')
132+
os.execute_or_exit('git checkout -b non_formatting_after_removing_hook')
133+
assert os.execute_or_exit('${os.quoted_path(vexe)} git-fmt-hook install').exit_code == 0
134+
assert os.is_file(hook_file)
135+
assert os.execute_or_exit('${os.quoted_path(vexe)} git-fmt-hook remove').exit_code == 0
136+
assert !os.is_file(hook_file)
137+
append('main.v', '\n') or { panic(err) }
138+
assert read_file('main.v').starts_with(unformatted_content)
139+
os.execute_or_exit('git add -u')
140+
os.execute_or_exit('git commit -m "this should NOT be formatted again"')
141+
assert read_file('main.v').starts_with(unformatted_content)
142+
}
143+
144+
fn test_run_git_fmt_hook_install_and_remove_on_foreign_hook_should_be_a_nop() {
145+
eprintln('>>>> ${@FN}')
146+
reset_to_start_state()
147+
os.execute_or_exit('git checkout start')
148+
os.execute_or_exit('git checkout -b install_and_remove_should_be_a_nop_on_a_foreign_hook')
149+
os.write_file(hook_file, foreign_script) or { panic(err) }
150+
os.chmod(hook_file, 0o0777) or { panic(err) }
151+
assert read_file(hook_file) == foreign_script
152+
assert os.execute_or_exit('${os.quoted_path(vexe)} git-fmt-hook install').exit_code == 0
153+
assert read_file(hook_file) == foreign_script
154+
assert os.execute_or_exit('${os.quoted_path(vexe)} git-fmt-hook remove').exit_code == 0
155+
assert read_file(hook_file) == foreign_script
156+
assert os.execute_or_exit('${os.quoted_path(vexe)} git-fmt-hook install').exit_code == 0
157+
assert read_file(hook_file) == foreign_script
158+
assert os.execute_or_exit('${os.quoted_path(vexe)} git-fmt-hook status').exit_code == 0
159+
assert read_file(hook_file) == foreign_script
160+
assert os.execute_or_exit('${os.quoted_path(vexe)} git-fmt-hook').exit_code == 0
161+
assert read_file(hook_file) == foreign_script
162+
append('main.v', '\n') or { panic(err) }
163+
append('main.v', '\n') or { panic(err) }
164+
assert read_file('main.v').starts_with(unformatted_content)
165+
os.execute_or_exit('git add -u')
166+
fcommiting := os.execute_or_exit('git commit -m "this should NOT be formatted 2"')
167+
assert fcommiting.exit_code == 0
168+
assert fcommiting.output.contains('hello hello')
169+
assert read_file('main.v').starts_with(unformatted_content)
170+
}
171+
172+
fn show_git_status() {
173+
os.system('git log --graph --all --decorate')
174+
os.system('git status')
175+
}
176+
177+
fn append(path string, content string) ! {
178+
mut f := os.open_append('main.v')!
179+
f.write_string(content)!
180+
f.close()
181+
}
182+
183+
fn read_file(path string) string {
184+
return os.read_file(path) or { panic(err) }
185+
}
186+
187+
fn reset_to_start_state() {
188+
os.execute('git checkout start')
189+
os.rm('.git/hooks/pre-commit') or {}
190+
assert read_file('main.v') == unformatted_content
191+
}
192+
193+
fn full_remove(path string) ! {
194+
// TODO: fix this on windows; the files inside .git/ are with read only permissions, and os.rmdir_all() can not delete them, until they are chmoded to writable
195+
files := os.walk_ext(path + '/.git', '')
196+
for f in files {
197+
os.chmod(f, 0o777) or {}
198+
}
199+
os.rmdir_all(path)!
200+
}

cmd/v/v.v

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const external_tools = [
3030
'doctor',
3131
'download',
3232
'fmt',
33+
'git-fmt-hook',
3334
'gret',
3435
'ls',
3536
'missdoc',

0 commit comments

Comments
 (0)