opencode-commit-guard 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -525,6 +525,7 @@ function extractInvocation(words, commandStart) {
525
525
  let hasSignoffFlag = false;
526
526
  let isAmend = false;
527
527
  let hasNoEdit = false;
528
+ let isFixup = false;
528
529
  let isHelp = false;
529
530
  const collectMessage = (word) => {
530
531
  if (word === undefined)
@@ -540,6 +541,8 @@ function extractInvocation(words, commandStart) {
540
541
  const argument = words[index];
541
542
  if (argument === undefined)
542
543
  break;
544
+ const equalsIndex = argument.indexOf("=");
545
+ const longOptionName = equalsIndex > 0 ? argument.slice(0, equalsIndex) : argument;
543
546
  if (argument === "--")
544
547
  break;
545
548
  if (argument === "-h" || argument === "--help") {
@@ -564,8 +567,13 @@ function extractInvocation(words, commandStart) {
564
567
  collectFile(words[index]);
565
568
  } else if (argument.startsWith("--file=")) {
566
569
  collectFile(argument.slice("--file=".length));
567
- } else if (commitLongOptionsWithArg.has(argument)) {
568
- index++;
570
+ } else if (argument === "--fixup" || argument.startsWith("--fixup=")) {
571
+ isFixup = true;
572
+ if (equalsIndex < 0)
573
+ index++;
574
+ } else if (commitLongOptionsWithArg.has(longOptionName)) {
575
+ if (equalsIndex < 0)
576
+ index++;
569
577
  } else if (argument.startsWith("-") && !argument.startsWith("--") && argument.length > 1) {
570
578
  for (let characterIndex = 1;characterIndex < argument.length; characterIndex++) {
571
579
  const option = argument[characterIndex];
@@ -601,6 +609,7 @@ function extractInvocation(words, commandStart) {
601
609
  hasSignoffFlag,
602
610
  isAmend,
603
611
  hasNoEdit,
612
+ isFixup,
604
613
  isHelp,
605
614
  directoryChanges
606
615
  };
@@ -624,6 +633,7 @@ function extractGitCommits(command) {
624
633
  }
625
634
 
626
635
  // src/validator.ts
636
+ import { spawnSync } from "child_process";
627
637
  import { closeSync, constants, existsSync, fstatSync, openSync, readSync, statSync } from "fs";
628
638
  import { resolve } from "path";
629
639
  var scopePattern = /^([a-zA-Z0-9_\-./]+(?:\([a-zA-Z0-9_\-./]+\))?):\s+(.+)$/;
@@ -670,14 +680,34 @@ function validateGitCommits(invocations, config, originalCommand, workingDirecto
670
680
  if (invocation.isHelp) {
671
681
  continue;
672
682
  }
673
- if (invocation.isAmend && invocation.hasNoEdit === true && invocation.messages.length === 0 && invocation.filePaths.length === 0) {
674
- continue;
675
- }
676
683
  const collectedMessages = [...invocation.messages];
677
684
  let messageDirectory = workingDirectory ?? process.cwd();
678
685
  for (const directoryChange of invocation.directoryChanges ?? []) {
679
686
  messageDirectory = resolve(messageDirectory, directoryChange);
680
687
  }
688
+ if (invocation.isAmend && invocation.hasNoEdit === true && invocation.messages.length === 0 && invocation.filePaths.length === 0) {
689
+ const result = spawnSync("git", ["log", "-1", "--format=%B", "HEAD"], {
690
+ cwd: messageDirectory,
691
+ encoding: "utf-8",
692
+ stdio: ["ignore", "pipe", "pipe"]
693
+ });
694
+ if (result.error !== undefined) {
695
+ allViolations.push(`Failed to read the existing HEAD commit message: ${result.error.message}`);
696
+ continue;
697
+ }
698
+ if (result.status !== 0) {
699
+ const detail = result.stderr.trim();
700
+ const suffix = detail.length > 0 ? `: ${detail}` : ".";
701
+ allViolations.push(`Failed to read the existing HEAD commit message${suffix}`);
702
+ continue;
703
+ }
704
+ const existingMessage = result.stdout.replace(/[\r\n]+$/, "");
705
+ if (existingMessage.trim().length === 0) {
706
+ allViolations.push("The existing HEAD commit message is empty.");
707
+ continue;
708
+ }
709
+ collectedMessages.push(existingMessage);
710
+ }
681
711
  const filePath = invocation.filePaths.at(-1);
682
712
  if (filePath !== undefined) {
683
713
  if (filePath === "-") {
@@ -722,6 +752,9 @@ function validateGitCommits(invocations, config, originalCommand, workingDirecto
722
752
  }
723
753
  }
724
754
  if (collectedMessages.length === 0) {
755
+ if (invocation.isFixup === true) {
756
+ continue;
757
+ }
725
758
  if (invocation.filePaths.length === 0) {
726
759
  allViolations.push('No commit message provided. Commits in OpenCode must provide a commit message via -m "<scope>: <subject>" or -F <file>.');
727
760
  }
@@ -733,21 +766,24 @@ function validateGitCommits(invocations, config, originalCommand, workingDirecto
733
766
  const lines = fullMessage.split(/\r?\n/);
734
767
  const firstLine = lines[0] ?? "";
735
768
  const subjectLine = firstLine.trim();
736
- const scopeMatch = subjectLine.match(scopePattern);
769
+ const effectiveSubject = subjectLine.replace(/^(?:(?:fixup|squash)!\s+)+/, "");
770
+ const scopeMatch = effectiveSubject.match(scopePattern);
737
771
  if (scopeMatch?.[1] !== undefined && config.allowedScopes !== undefined && config.allowedScopes.length > 0) {
738
772
  const scopeViolation = validateAllowedScope(scopeMatch[1], config.allowedScopes);
739
- if (scopeViolation !== undefined)
740
- allViolations.push(scopeViolation);
773
+ if (scopeViolation !== undefined) {
774
+ const fullSubjectDetail = effectiveSubject === subjectLine ? "" : ` Subject line: "${subjectLine}".`;
775
+ allViolations.push(`${scopeViolation}${fullSubjectDetail}`);
776
+ }
741
777
  }
742
778
  if (config.requireScope) {
743
779
  if (subjectLine.length === 0) {
744
780
  allViolations.push('Subject line is empty. The commit message must begin with "<scope>: <subject>".');
745
781
  } else if (scopeMatch === null) {
746
- if (/^:\s*/.test(subjectLine)) {
782
+ if (/^:\s*/.test(effectiveSubject)) {
747
783
  allViolations.push(`Missing scope before colon in subject line "${subjectLine}". Expected format: "<scope>: <subject>".`);
748
- } else if (/^[^:]+:\S/.test(subjectLine)) {
784
+ } else if (/^[^:]+:\S/.test(effectiveSubject)) {
749
785
  allViolations.push(`Missing space after colon in subject line "${subjectLine}". Expected format: "<scope>: <subject>".`);
750
- } else if (/^[^:]+:\s*$/.test(subjectLine)) {
786
+ } else if (/^[^:]+:\s*$/.test(effectiveSubject)) {
751
787
  allViolations.push(`Subject text after colon is empty in "${subjectLine}". Expected format: "<scope>: <subject>".`);
752
788
  } else {
753
789
  allViolations.push(`Missing scope in subject line "${subjectLine}". First line must follow "<scope>: <subject>" format (e.g., "kernel: add support for foo" or "feat(parser): add subshell support").`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-commit-guard",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "OpenCode V2 plugin enforcing git commit format rules",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/shell.ts CHANGED
@@ -563,6 +563,7 @@ function extractInvocation(
563
563
  let hasSignoffFlag = false
564
564
  let isAmend = false
565
565
  let hasNoEdit = false
566
+ let isFixup = false
566
567
  let isHelp = false
567
568
 
568
569
  const collectMessage = (word: string | undefined): void => {
@@ -580,6 +581,9 @@ function extractInvocation(
580
581
 
581
582
  if (argument === undefined) break
582
583
 
584
+ const equalsIndex = argument.indexOf("=")
585
+ const longOptionName = equalsIndex > 0 ? argument.slice(0, equalsIndex) : argument
586
+
583
587
  if (argument === "--") break
584
588
 
585
589
  if (argument === "-h" || argument === "--help") {
@@ -604,8 +608,12 @@ function extractInvocation(
604
608
  collectFile(words[index])
605
609
  } else if (argument.startsWith("--file=")) {
606
610
  collectFile(argument.slice("--file=".length))
607
- } else if (commitLongOptionsWithArg.has(argument)) {
608
- index++
611
+ } else if (argument === "--fixup" || argument.startsWith("--fixup=")) {
612
+ isFixup = true
613
+
614
+ if (equalsIndex < 0) index++
615
+ } else if (commitLongOptionsWithArg.has(longOptionName)) {
616
+ if (equalsIndex < 0) index++
609
617
  } else if (argument.startsWith("-") && !argument.startsWith("--") && argument.length > 1) {
610
618
  for (let characterIndex = 1; characterIndex < argument.length; characterIndex++) {
611
619
  const option = argument[characterIndex]
@@ -644,6 +652,7 @@ function extractInvocation(
644
652
  hasSignoffFlag,
645
653
  isAmend,
646
654
  hasNoEdit,
655
+ isFixup,
647
656
  isHelp,
648
657
  directoryChanges,
649
658
  }
package/src/types.ts CHANGED
@@ -49,6 +49,7 @@ export interface GitCommitInvocation {
49
49
  readonly hasSignoffFlag: boolean
50
50
  readonly isAmend: boolean
51
51
  readonly hasNoEdit?: boolean
52
+ readonly isFixup?: boolean
52
53
  readonly isHelp: boolean
53
54
  readonly directoryChanges?: readonly string[]
54
55
  }
package/src/validator.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process"
1
2
  import { closeSync, constants, existsSync, fstatSync, openSync, readSync, statSync } from "node:fs"
2
3
  import { resolve } from "node:path"
3
4
  import type {
@@ -68,10 +69,6 @@ export function validateGitCommits(
68
69
  continue
69
70
  }
70
71
 
71
- if (invocation.isAmend && invocation.hasNoEdit === true && invocation.messages.length === 0 && invocation.filePaths.length === 0) {
72
- continue
73
- }
74
-
75
72
  const collectedMessages: string[] = [...invocation.messages]
76
73
  let messageDirectory = workingDirectory ?? process.cwd()
77
74
 
@@ -79,6 +76,40 @@ export function validateGitCommits(
79
76
  messageDirectory = resolve(messageDirectory, directoryChange)
80
77
  }
81
78
 
79
+ if (
80
+ invocation.isAmend &&
81
+ invocation.hasNoEdit === true &&
82
+ invocation.messages.length === 0 &&
83
+ invocation.filePaths.length === 0
84
+ ) {
85
+ const result = spawnSync("git", ["log", "-1", "--format=%B", "HEAD"], {
86
+ cwd: messageDirectory,
87
+ encoding: "utf-8",
88
+ stdio: ["ignore", "pipe", "pipe"],
89
+ })
90
+
91
+ if (result.error !== undefined) {
92
+ allViolations.push(`Failed to read the existing HEAD commit message: ${result.error.message}`)
93
+ continue
94
+ }
95
+
96
+ if (result.status !== 0) {
97
+ const detail = result.stderr.trim()
98
+ const suffix = detail.length > 0 ? `: ${detail}` : "."
99
+ allViolations.push(`Failed to read the existing HEAD commit message${suffix}`)
100
+ continue
101
+ }
102
+
103
+ const existingMessage = result.stdout.replace(/[\r\n]+$/, "")
104
+
105
+ if (existingMessage.trim().length === 0) {
106
+ allViolations.push("The existing HEAD commit message is empty.")
107
+ continue
108
+ }
109
+
110
+ collectedMessages.push(existingMessage)
111
+ }
112
+
82
113
  const filePath = invocation.filePaths.at(-1)
83
114
 
84
115
  if (filePath !== undefined) {
@@ -136,6 +167,10 @@ export function validateGitCommits(
136
167
  }
137
168
 
138
169
  if (collectedMessages.length === 0) {
170
+ if (invocation.isFixup === true) {
171
+ continue
172
+ }
173
+
139
174
  if (invocation.filePaths.length === 0) {
140
175
  allViolations.push(
141
176
  'No commit message provided. Commits in OpenCode must provide a commit message via -m "<scope>: <subject>" or -F <file>.',
@@ -149,7 +184,8 @@ export function validateGitCommits(
149
184
  const lines = fullMessage.split(/\r?\n/)
150
185
  const firstLine = lines[0] ?? ""
151
186
  const subjectLine = firstLine.trim()
152
- const scopeMatch = subjectLine.match(scopePattern)
187
+ const effectiveSubject = subjectLine.replace(/^(?:(?:fixup|squash)!\s+)+/, "")
188
+ const scopeMatch = effectiveSubject.match(scopePattern)
153
189
 
154
190
  if (
155
191
  scopeMatch?.[1] !== undefined &&
@@ -158,22 +194,28 @@ export function validateGitCommits(
158
194
  ) {
159
195
  const scopeViolation = validateAllowedScope(scopeMatch[1], config.allowedScopes)
160
196
 
161
- if (scopeViolation !== undefined) allViolations.push(scopeViolation)
197
+ if (scopeViolation !== undefined) {
198
+ const fullSubjectDetail = effectiveSubject === subjectLine
199
+ ? ""
200
+ : ` Subject line: "${subjectLine}".`
201
+
202
+ allViolations.push(`${scopeViolation}${fullSubjectDetail}`)
203
+ }
162
204
  }
163
205
 
164
206
  if (config.requireScope) {
165
207
  if (subjectLine.length === 0) {
166
208
  allViolations.push('Subject line is empty. The commit message must begin with "<scope>: <subject>".')
167
209
  } else if (scopeMatch === null) {
168
- if (/^:\s*/.test(subjectLine)) {
210
+ if (/^:\s*/.test(effectiveSubject)) {
169
211
  allViolations.push(
170
212
  `Missing scope before colon in subject line "${subjectLine}". Expected format: "<scope>: <subject>".`,
171
213
  )
172
- } else if (/^[^:]+:\S/.test(subjectLine)) {
214
+ } else if (/^[^:]+:\S/.test(effectiveSubject)) {
173
215
  allViolations.push(
174
216
  `Missing space after colon in subject line "${subjectLine}". Expected format: "<scope>: <subject>".`,
175
217
  )
176
- } else if (/^[^:]+:\s*$/.test(subjectLine)) {
218
+ } else if (/^[^:]+:\s*$/.test(effectiveSubject)) {
177
219
  allViolations.push(
178
220
  `Subject text after colon is empty in "${subjectLine}". Expected format: "<scope>: <subject>".`,
179
221
  )