1- import { describe , it , expect , beforeEach , afterEach , vi , type Mock } from 'vitest' ;
1+ import { describe , it , expect , beforeEach , afterEach , vi } from 'vitest' ;
22import { join } from 'path' ;
33import { mkdtempSync , mkdirSync , rmSync , writeFileSync } from 'fs' ;
44import { tmpdir } from 'os' ;
@@ -7,10 +7,11 @@ import * as jobState from '../../src/lib/job-state';
77import * as worktree from '../../src/lib/worktree' ;
88import * as config from '../../src/lib/config' ;
99import * as planState from '../../src/lib/plan-state' ;
10+ import * as git from '../../src/lib/git' ;
1011
1112const { mc_merge } = await import ( '../../src/tools/merge' ) ;
1213
13- let mockGetJobByName : Mock ;
14+ let mockGetJobByName : any ;
1415
1516const mockContext = {
1617 sessionID : 'test-session' ,
@@ -25,8 +26,42 @@ const mockContext = {
2526
2627describe ( 'mc_merge' , ( ) => {
2728 beforeEach ( ( ) => {
28- vi . clearAllMocks ( ) ;
29+ vi . restoreAllMocks ( ) ;
2930 mockGetJobByName = vi . spyOn ( jobState , 'getJobByName' ) . mockImplementation ( ( ) => undefined as any ) ;
31+ vi . spyOn ( worktree , 'getMainWorktree' ) . mockResolvedValue ( '/tmp/mc-merge-mock-main' ) ;
32+ vi . spyOn ( config , 'loadConfig' ) . mockResolvedValue ( { mergeStrategy : 'squash' } as any ) ;
33+ vi . spyOn ( planState , 'loadPlan' ) . mockResolvedValue ( null ) ;
34+ vi . spyOn ( git , 'gitCommand' ) . mockImplementation ( async ( args : string [ ] ) => {
35+ if (
36+ args [ 0 ] === 'rev-parse' &&
37+ args . includes ( '--verify' ) &&
38+ args [ args . length - 1 ] === 'main'
39+ ) {
40+ return { stdout : 'main' , stderr : '' , exitCode : 0 } ;
41+ }
42+
43+ if (
44+ args [ 0 ] === 'rev-parse' &&
45+ args . includes ( '--verify' ) &&
46+ args [ args . length - 1 ] === 'master'
47+ ) {
48+ return { stdout : '' , stderr : '' , exitCode : 1 } ;
49+ }
50+
51+ if ( args [ 0 ] === 'rev-parse' && args . includes ( '--abbrev-ref' ) ) {
52+ return { stdout : 'main' , stderr : '' , exitCode : 0 } ;
53+ }
54+
55+ if ( args [ 0 ] === 'merge' && args . includes ( '--squash' ) ) {
56+ return { stdout : '' , stderr : 'mock squash merge failure' , exitCode : 1 } ;
57+ }
58+
59+ return { stdout : '' , stderr : '' , exitCode : 0 } ;
60+ } ) ;
61+ } ) ;
62+
63+ afterEach ( ( ) => {
64+ vi . restoreAllMocks ( ) ;
3065 } ) ;
3166
3267 describe ( 'tool definition' , ( ) => {
@@ -59,6 +94,49 @@ describe('mc_merge', () => {
5994 } ) ;
6095 } ) ;
6196
97+ describe ( 'safety guard' , ( ) => {
98+ it ( 'should refuse merge when main worktree has uncommitted changes' , async ( ) => {
99+ const job : Job = {
100+ id : 'job-dirty' ,
101+ name : 'dirty-merge' ,
102+ worktreePath : '/tmp/mc-worktrees/dirty-merge' ,
103+ branch : 'mc/dirty-merge' ,
104+ tmuxTarget : 'mc-dirty-merge' ,
105+ placement : 'session' ,
106+ status : 'running' ,
107+ prompt : 'Dirty merge test' ,
108+ mode : 'vanilla' ,
109+ createdAt : new Date ( ) . toISOString ( ) ,
110+ } ;
111+
112+ mockGetJobByName . mockResolvedValue ( job ) ;
113+
114+ ( git . gitCommand as any ) . mockImplementation ( async ( args : string [ ] ) => {
115+ if (
116+ args [ 0 ] === 'rev-parse' &&
117+ args . includes ( '--verify' ) &&
118+ args [ args . length - 1 ] === 'main'
119+ ) {
120+ return { stdout : 'main' , stderr : '' , exitCode : 0 } ;
121+ }
122+
123+ if ( args [ 0 ] === 'rev-parse' && args . includes ( '--abbrev-ref' ) ) {
124+ return { stdout : 'main' , stderr : '' , exitCode : 0 } ;
125+ }
126+
127+ if ( args [ 0 ] === 'status' && args . includes ( '--porcelain' ) ) {
128+ return { stdout : ' M README.md' , stderr : '' , exitCode : 0 } ;
129+ }
130+
131+ return { stdout : '' , stderr : '' , exitCode : 0 } ;
132+ } ) ;
133+
134+ await expect (
135+ mc_merge . execute ( { name : 'dirty-merge' } , mockContext ) ,
136+ ) . rejects . toThrow ( 'Main worktree has uncommitted changes' ) ;
137+ } ) ;
138+ } ) ;
139+
62140 describe ( 'tool args validation' , ( ) => {
63141 it ( 'should have name as required arg' , ( ) => {
64142 const nameArg = mc_merge . args . name ;
0 commit comments