@gitgov/core 1.8.0 → 1.8.3

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/src/index.js CHANGED
@@ -3000,12 +3000,61 @@ var ConfigManager = class _ConfigManager {
3000
3000
  }
3001
3001
  /**
3002
3002
  * Load GitGovernance session state
3003
+ * [EARS-53] Auto-detects actor from .key files if no session or no actorId exists
3003
3004
  */
3004
3005
  async loadSession() {
3005
3006
  try {
3006
3007
  const sessionContent = await promises.readFile(this.sessionPath, "utf-8");
3007
- return JSON.parse(sessionContent);
3008
+ const session = JSON.parse(sessionContent);
3009
+ if (!session.lastSession?.actorId) {
3010
+ const detectedActorId = await this.detectActorFromKeyFiles();
3011
+ if (detectedActorId) {
3012
+ session.lastSession = {
3013
+ actorId: detectedActorId,
3014
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3015
+ };
3016
+ await promises.writeFile(this.sessionPath, JSON.stringify(session, null, 2), "utf-8");
3017
+ }
3018
+ }
3019
+ return session;
3008
3020
  } catch (error) {
3021
+ const detectedActorId = await this.detectActorFromKeyFiles();
3022
+ if (detectedActorId) {
3023
+ const newSession = {
3024
+ lastSession: {
3025
+ actorId: detectedActorId,
3026
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3027
+ },
3028
+ actorState: {}
3029
+ };
3030
+ try {
3031
+ await promises.writeFile(this.sessionPath, JSON.stringify(newSession, null, 2), "utf-8");
3032
+ return newSession;
3033
+ } catch {
3034
+ return newSession;
3035
+ }
3036
+ }
3037
+ return null;
3038
+ }
3039
+ }
3040
+ /**
3041
+ * [EARS-53] Detect actor from .key files in .gitgov/actors/
3042
+ * Returns the actor ID if exactly one .key file exists, or the first one if multiple exist.
3043
+ * Private keys (.key files) indicate which actors can sign on this machine.
3044
+ */
3045
+ async detectActorFromKeyFiles() {
3046
+ try {
3047
+ const gitgovDir = path6.dirname(this.sessionPath);
3048
+ const actorsDir = path6.join(gitgovDir, "actors");
3049
+ const files = await promises.readdir(actorsDir);
3050
+ const keyFiles = files.filter((f) => f.endsWith(".key"));
3051
+ const firstKeyFile = keyFiles[0];
3052
+ if (!firstKeyFile) {
3053
+ return null;
3054
+ }
3055
+ const actorId = firstKeyFile.replace(".key", "");
3056
+ return actorId;
3057
+ } catch {
3009
3058
  return null;
3010
3059
  }
3011
3060
  }
@@ -3598,7 +3647,7 @@ var IdentityAdapter = class {
3598
3647
  }
3599
3648
  return actors;
3600
3649
  }
3601
- async signRecord(record, actorId, role) {
3650
+ async signRecord(record, actorId, role, notes) {
3602
3651
  const actor = await this.getActor(actorId);
3603
3652
  if (!actor) {
3604
3653
  throw new Error(`Actor not found: ${actorId}`);
@@ -3617,12 +3666,12 @@ var IdentityAdapter = class {
3617
3666
  }
3618
3667
  let signature;
3619
3668
  if (privateKey) {
3620
- signature = signPayload(record.payload, privateKey, actorId, role, "Record signed");
3669
+ signature = signPayload(record.payload, privateKey, actorId, role, notes);
3621
3670
  } else {
3622
3671
  signature = {
3623
3672
  keyId: actorId,
3624
3673
  role,
3625
- notes: "Record signed",
3674
+ notes,
3626
3675
  signature: `mock-signature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
3627
3676
  timestamp: Math.floor(Date.now() / 1e3)
3628
3677
  };
@@ -4069,7 +4118,7 @@ var FeedbackAdapter = class {
4069
4118
  },
4070
4119
  payload: validatedPayload
4071
4120
  };
4072
- const signedRecord = await this.identity.signRecord(unsignedRecord, actorId, "author");
4121
+ const signedRecord = await this.identity.signRecord(unsignedRecord, actorId, "author", "Feedback record created");
4073
4122
  await this.feedbackStore.write(signedRecord);
4074
4123
  this.eventBus.publish({
4075
4124
  type: "feedback.created",
@@ -4336,7 +4385,7 @@ var ExecutionAdapter = class {
4336
4385
  },
4337
4386
  payload: validatedPayload
4338
4387
  };
4339
- const signedRecord = await this.identity.signRecord(unsignedRecord, actorId, "author");
4388
+ const signedRecord = await this.identity.signRecord(unsignedRecord, actorId, "author", "Execution record created");
4340
4389
  await this.executionStore.write(signedRecord);
4341
4390
  this.eventBus.publish({
4342
4391
  type: "execution.created",
@@ -4568,7 +4617,7 @@ var ChangelogAdapter = class {
4568
4617
  },
4569
4618
  payload: validatedPayload
4570
4619
  };
4571
- const signedRecord = await this.identity.signRecord(unsignedRecord, actorId, "author");
4620
+ const signedRecord = await this.identity.signRecord(unsignedRecord, actorId, "author", "Changelog record created");
4572
4621
  await this.changelogStore.write(signedRecord);
4573
4622
  this.eventBus.publish({
4574
4623
  type: "changelog.created",
@@ -5264,7 +5313,7 @@ var BacklogAdapter = class {
5264
5313
  },
5265
5314
  payload: validatedPayload
5266
5315
  };
5267
- const signedRecord = await this.identity.signRecord(unsignedRecord, actorId, "author");
5316
+ const signedRecord = await this.identity.signRecord(unsignedRecord, actorId, "author", "Task created");
5268
5317
  await this.taskStore.write(signedRecord);
5269
5318
  this.eventBus.publish({
5270
5319
  type: "task.created",
@@ -5326,7 +5375,7 @@ var BacklogAdapter = class {
5326
5375
  }
5327
5376
  const updatedPayload = { ...task, status: "review" };
5328
5377
  const updatedRecord = { ...taskRecord, payload: updatedPayload };
5329
- const signedRecord = await this.identity.signRecord(updatedRecord, actorId, "submitter");
5378
+ const signedRecord = await this.identity.signRecord(updatedRecord, actorId, "submitter", "Task submitted for review");
5330
5379
  await this.taskStore.write(signedRecord);
5331
5380
  this.eventBus.publish({
5332
5381
  type: "task.status.changed",
@@ -5378,7 +5427,7 @@ var BacklogAdapter = class {
5378
5427
  }
5379
5428
  const updatedPayload = { ...task, status: targetState };
5380
5429
  const updatedRecord = { ...taskRecord, payload: updatedPayload };
5381
- const signedRecord = await this.identity.signRecord(updatedRecord, actorId, "approver");
5430
+ const signedRecord = await this.identity.signRecord(updatedRecord, actorId, "approver", `Task approved: ${task.status} \u2192 ${targetState}`);
5382
5431
  await this.taskStore.write(signedRecord);
5383
5432
  this.eventBus.publish({
5384
5433
  type: "task.status.changed",
@@ -5418,7 +5467,7 @@ var BacklogAdapter = class {
5418
5467
  }
5419
5468
  const updatedPayload = { ...task, status: "active" };
5420
5469
  const updatedRecord = { ...taskRecord, payload: updatedPayload };
5421
- const signedRecord = await this.identity.signRecord(updatedRecord, actorId, "executor");
5470
+ const signedRecord = await this.identity.signRecord(updatedRecord, actorId, "executor", "Task activated");
5422
5471
  await this.taskStore.write(signedRecord);
5423
5472
  await this.configManager.updateActorState(actorId, {
5424
5473
  activeTaskId: taskId
@@ -5469,7 +5518,7 @@ var BacklogAdapter = class {
5469
5518
  }
5470
5519
  };
5471
5520
  const updatedRecord = { ...taskRecord, payload: updatedPayload };
5472
- const signedRecord = await this.identity.signRecord(updatedRecord, actorId, "pauser");
5521
+ const signedRecord = await this.identity.signRecord(updatedRecord, actorId, "pauser", `Task paused: ${reason || "No reason provided"}`);
5473
5522
  await this.taskStore.write(signedRecord);
5474
5523
  await this.configManager.updateActorState(actorId, {
5475
5524
  activeTaskId: void 0
@@ -5519,7 +5568,7 @@ var BacklogAdapter = class {
5519
5568
  }
5520
5569
  const updatedPayload = { ...task, status: "active" };
5521
5570
  const updatedRecord = { ...taskRecord, payload: updatedPayload };
5522
- const signedRecord = await this.identity.signRecord(updatedRecord, actorId, "resumer");
5571
+ const signedRecord = await this.identity.signRecord(updatedRecord, actorId, "resumer", "Task resumed");
5523
5572
  await this.taskStore.write(signedRecord);
5524
5573
  await this.configManager.updateActorState(actorId, {
5525
5574
  activeTaskId: taskId
@@ -5562,7 +5611,7 @@ var BacklogAdapter = class {
5562
5611
  }
5563
5612
  const updatedPayload = { ...task, status: "done" };
5564
5613
  const updatedRecord = { ...taskRecord, payload: updatedPayload };
5565
- const signedRecord = await this.identity.signRecord(updatedRecord, actorId, "approver");
5614
+ const signedRecord = await this.identity.signRecord(updatedRecord, actorId, "approver", "Task completed");
5566
5615
  await this.taskStore.write(signedRecord);
5567
5616
  await this.configManager.updateActorState(actorId, {
5568
5617
  activeTaskId: void 0
@@ -5617,7 +5666,7 @@ ${task.status === "review" ? "[REJECTED]" : "[CANCELLED]"} ${reason} (${(/* @__P
5617
5666
  }
5618
5667
  };
5619
5668
  const updatedRecord = { ...taskRecord, payload: updatedPayload };
5620
- const signedRecord = await this.identity.signRecord(updatedRecord, actorId, "canceller");
5669
+ const signedRecord = await this.identity.signRecord(updatedRecord, actorId, "canceller", `Task discarded: ${reason || "No reason provided"}`);
5621
5670
  await this.taskStore.write(signedRecord);
5622
5671
  await this.configManager.updateActorState(actorId, {
5623
5672
  activeTaskId: void 0
@@ -5671,8 +5720,9 @@ ${task.status === "review" ? "[REJECTED]" : "[CANCELLED]"} ${reason} (${(/* @__P
5671
5720
  }
5672
5721
  /**
5673
5722
  * Updates a task with new payload
5723
+ * [EARS-28] Signs the updated record with the editor's signature
5674
5724
  */
5675
- async updateTask(taskId, payload) {
5725
+ async updateTask(taskId, payload, actorId) {
5676
5726
  const taskRecord = await this.taskStore.read(taskId);
5677
5727
  if (!taskRecord) {
5678
5728
  throw new Error(`RecordNotFoundError: Task not found: ${taskId}`);
@@ -5682,7 +5732,8 @@ ${task.status === "review" ? "[REJECTED]" : "[CANCELLED]"} ${reason} (${(/* @__P
5682
5732
  }
5683
5733
  const updatedPayload = createTaskRecord({ ...taskRecord.payload, ...payload });
5684
5734
  const updatedRecord = { ...taskRecord, payload: updatedPayload };
5685
- await this.taskStore.write(updatedRecord);
5735
+ const signedRecord = await this.identity.signRecord(updatedRecord, actorId, "editor", "Task updated");
5736
+ await this.taskStore.write(signedRecord);
5686
5737
  return updatedPayload;
5687
5738
  }
5688
5739
  // ===== PHASE 2: AGENT NAVIGATION (IMPLEMENTED) =====
@@ -5993,7 +6044,7 @@ ${task.status === "review" ? "[REJECTED]" : "[CANCELLED]"} ${reason} (${(/* @__P
5993
6044
  },
5994
6045
  payload: validatedPayload
5995
6046
  };
5996
- const signedRecord = await this.identity.signRecord(unsignedRecord, actorId, "author");
6047
+ const signedRecord = await this.identity.signRecord(unsignedRecord, actorId, "author", "Cycle created");
5997
6048
  await this.cycleStore.write(signedRecord);
5998
6049
  this.eventBus.publish({
5999
6050
  type: "cycle.created",
@@ -6096,12 +6147,14 @@ ${task.status === "review" ? "[REJECTED]" : "[CANCELLED]"} ${reason} (${(/* @__P
6096
6147
  const signedCycleRecord = await this.identity.signRecord(
6097
6148
  { ...cycleRecord, payload: updatedCycle },
6098
6149
  currentActor.id,
6099
- "author"
6150
+ "author",
6151
+ `Task ${taskId} added to cycle`
6100
6152
  );
6101
6153
  const signedTaskRecord = await this.identity.signRecord(
6102
6154
  { ...taskRecord, payload: updatedTask },
6103
6155
  currentActor.id,
6104
- "author"
6156
+ "author",
6157
+ `Task linked to cycle ${cycleId}`
6105
6158
  );
6106
6159
  await Promise.all([
6107
6160
  this.cycleStore.write(signedCycleRecord),
@@ -6145,7 +6198,8 @@ ${task.status === "review" ? "[REJECTED]" : "[CANCELLED]"} ${reason} (${(/* @__P
6145
6198
  const signedCycleRecord = await this.identity.signRecord(
6146
6199
  { ...cycleRecord, payload: updatedCycle },
6147
6200
  currentActor.id,
6148
- "author"
6201
+ "author",
6202
+ `Tasks removed from cycle: ${taskIds.join(", ")}`
6149
6203
  );
6150
6204
  const signedTaskRecords = await Promise.all(
6151
6205
  taskRecords.map(async ({ record }) => {
@@ -6157,7 +6211,8 @@ ${task.status === "review" ? "[REJECTED]" : "[CANCELLED]"} ${reason} (${(/* @__P
6157
6211
  return await this.identity.signRecord(
6158
6212
  { ...record, payload: updatedTask },
6159
6213
  currentActor.id,
6160
- "author"
6214
+ "author",
6215
+ "Task removed from deleted cycle"
6161
6216
  );
6162
6217
  })
6163
6218
  );
@@ -6223,12 +6278,14 @@ ${task.status === "review" ? "[REJECTED]" : "[CANCELLED]"} ${reason} (${(/* @__P
6223
6278
  this.identity.signRecord(
6224
6279
  { ...sourceCycleRecord, payload: updatedSourceCycle },
6225
6280
  currentActor.id,
6226
- "author"
6281
+ "author",
6282
+ "Tasks moved from cycle"
6227
6283
  ),
6228
6284
  this.identity.signRecord(
6229
6285
  { ...targetCycleRecord, payload: updatedTargetCycle },
6230
6286
  currentActor.id,
6231
- "author"
6287
+ "author",
6288
+ "Tasks moved to cycle"
6232
6289
  )
6233
6290
  ]);
6234
6291
  const signedTaskRecords = await Promise.all(
@@ -6242,7 +6299,8 @@ ${task.status === "review" ? "[REJECTED]" : "[CANCELLED]"} ${reason} (${(/* @__P
6242
6299
  return await this.identity.signRecord(
6243
6300
  { ...record, payload: updatedTask },
6244
6301
  currentActor.id,
6245
- "author"
6302
+ "author",
6303
+ "Task cycle updated"
6246
6304
  );
6247
6305
  })
6248
6306
  );
@@ -7254,7 +7312,7 @@ var ProjectAdapter = class {
7254
7312
  const projectRoot2 = process.env["GITGOV_ORIGINAL_DIR"] || process.cwd();
7255
7313
  const gitgovPath = path6.join(projectRoot2, ".gitgov");
7256
7314
  await this.createDirectoryStructure(gitgovPath);
7257
- await this.copyAgentPrompt(gitgovPath);
7315
+ await this.copyAgentPrompt(projectRoot2);
7258
7316
  const actor = await this.identityAdapter.createActor(
7259
7317
  {
7260
7318
  type: "human",
@@ -7333,7 +7391,8 @@ var ProjectAdapter = class {
7333
7391
  }
7334
7392
  console.warn(`\u26A0\uFE0F ${warnings.join(", ")}.`);
7335
7393
  console.warn(` State sync will be available after 'git remote add origin <url>' and first commit.`);
7336
- console.warn(` Run 'gitgov sync push' when ready to enable multi-machine collaboration.`);
7394
+ console.warn(` Run 'gitgov sync push' when ready to enable multi-machine collaboration.
7395
+ `);
7337
7396
  }
7338
7397
  await this.initializeSession(actor.id, gitgovPath);
7339
7398
  await this.setupGitIntegration(projectRoot2);
@@ -7552,8 +7611,8 @@ var ProjectAdapter = class {
7552
7611
  await promises.mkdir(path6.join(gitgovPath, dir), { recursive: true });
7553
7612
  }
7554
7613
  }
7555
- async copyAgentPrompt(gitgovPath) {
7556
- const targetPrompt = path6.join(gitgovPath, "gitgov");
7614
+ async copyAgentPrompt(projectRoot2) {
7615
+ const targetPrompt = path6.join(projectRoot2, "gitgov");
7557
7616
  const potentialSources = [];
7558
7617
  potentialSources.push(
7559
7618
  path6.join(process.cwd(), "prompts/gitgov_agent_prompt.md")
@@ -7583,7 +7642,7 @@ var ProjectAdapter = class {
7583
7642
  try {
7584
7643
  await promises.access(source);
7585
7644
  await promises.copyFile(source, targetPrompt);
7586
- logger3.debug(`\u{1F4CB} @gitgov agent prompt copied to .gitgov/gitgov
7645
+ logger3.debug(`\u{1F4CB} @gitgov agent prompt copied to project root (./gitgov)
7587
7646
  `);
7588
7647
  return;
7589
7648
  } catch {
@@ -7625,8 +7684,8 @@ var ProjectAdapter = class {
7625
7684
  # Ignore entire .gitgov/ directory (state lives in gitgov-state branch)
7626
7685
  .gitgov/
7627
7686
 
7628
- # Exception: Don't ignore .gitgov/.gitignore itself (meta!)
7629
- !.gitgov/.gitignore
7687
+ # Ignore agent prompt file (project-specific, created by gitgov init)
7688
+ gitgov
7630
7689
  `;
7631
7690
  try {
7632
7691
  let existingContent = "";
@@ -11355,7 +11414,31 @@ function shouldSyncFile(filePath) {
11355
11414
  if (LOCAL_ONLY_FILES.includes(fileName)) {
11356
11415
  return false;
11357
11416
  }
11358
- return true;
11417
+ const normalizedPath = filePath.replace(/\\/g, "/");
11418
+ const parts = normalizedPath.split("/");
11419
+ const gitgovIndex = parts.findIndex((p) => p === ".gitgov");
11420
+ let relativeParts;
11421
+ if (gitgovIndex !== -1) {
11422
+ relativeParts = parts.slice(gitgovIndex + 1);
11423
+ } else {
11424
+ const syncDirIndex = parts.findIndex(
11425
+ (p) => SYNC_DIRECTORIES.includes(p)
11426
+ );
11427
+ if (syncDirIndex !== -1) {
11428
+ relativeParts = parts.slice(syncDirIndex);
11429
+ } else if (SYNC_ROOT_FILES.includes(fileName)) {
11430
+ return true;
11431
+ } else {
11432
+ return false;
11433
+ }
11434
+ }
11435
+ if (relativeParts.length === 1) {
11436
+ return SYNC_ROOT_FILES.includes(relativeParts[0]);
11437
+ } else if (relativeParts.length >= 2) {
11438
+ const dirName = relativeParts[0];
11439
+ return SYNC_DIRECTORIES.includes(dirName);
11440
+ }
11441
+ return false;
11359
11442
  }
11360
11443
  async function getAllFiles(dir, baseDir = dir) {
11361
11444
  const files = [];
@@ -11374,7 +11457,7 @@ async function getAllFiles(dir, baseDir = dir) {
11374
11457
  }
11375
11458
  return files;
11376
11459
  }
11377
- async function copySyncableFiles(sourceDir, destDir, log) {
11460
+ async function copySyncableFiles(sourceDir, destDir, log, excludeFiles = /* @__PURE__ */ new Set()) {
11378
11461
  let copiedCount = 0;
11379
11462
  for (const dirName of SYNC_DIRECTORIES) {
11380
11463
  const sourcePath = path6__default.join(sourceDir, dirName);
@@ -11386,6 +11469,11 @@ async function copySyncableFiles(sourceDir, destDir, log) {
11386
11469
  for (const relativePath of allFiles) {
11387
11470
  const fullSourcePath = path6__default.join(sourcePath, relativePath);
11388
11471
  const fullDestPath = path6__default.join(destPath, relativePath);
11472
+ const gitgovRelativePath = `.gitgov/${dirName}/${relativePath}`;
11473
+ if (excludeFiles.has(gitgovRelativePath)) {
11474
+ log(`[EARS-60] Skipped (remote-only change preserved): ${gitgovRelativePath}`);
11475
+ continue;
11476
+ }
11389
11477
  if (shouldSyncFile(fullSourcePath)) {
11390
11478
  await promises.mkdir(path6__default.dirname(fullDestPath), { recursive: true });
11391
11479
  await promises.copyFile(fullSourcePath, fullDestPath);
@@ -11405,6 +11493,11 @@ async function copySyncableFiles(sourceDir, destDir, log) {
11405
11493
  for (const fileName of SYNC_ROOT_FILES) {
11406
11494
  const sourcePath = path6__default.join(sourceDir, fileName);
11407
11495
  const destPath = path6__default.join(destDir, fileName);
11496
+ const gitgovRelativePath = `.gitgov/${fileName}`;
11497
+ if (excludeFiles.has(gitgovRelativePath)) {
11498
+ log(`[EARS-60] Skipped (remote-only change preserved): ${gitgovRelativePath}`);
11499
+ continue;
11500
+ }
11408
11501
  try {
11409
11502
  await promises.copyFile(sourcePath, destPath);
11410
11503
  log(`Copied root file: ${fileName}`);
@@ -11628,7 +11721,28 @@ var SyncModule = class {
11628
11721
  await this.git.checkoutOrphanBranch(stateBranch);
11629
11722
  try {
11630
11723
  await execAsync("git rm -rf . 2>/dev/null || true", { cwd: repoRoot });
11631
- await execAsync('git commit --allow-empty -m "Initialize state branch"', { cwd: repoRoot });
11724
+ const gitignoreContent = `# GitGovernance State Branch .gitignore
11725
+ # This file is auto-generated during gitgov init
11726
+ # These files are machine-specific and should NOT be synced
11727
+
11728
+ # Local-only files (regenerated/machine-specific)
11729
+ index.json
11730
+ .session.json
11731
+ gitgov
11732
+
11733
+ # Private keys (never synced for security)
11734
+ *.key
11735
+
11736
+ # Backup and temporary files
11737
+ *.backup
11738
+ *.backup-*
11739
+ *.tmp
11740
+ *.bak
11741
+ `;
11742
+ const gitignorePath = path6__default.join(repoRoot, ".gitignore");
11743
+ await promises.writeFile(gitignorePath, gitignoreContent, "utf-8");
11744
+ await execAsync("git add .gitignore", { cwd: repoRoot });
11745
+ await execAsync('git commit -m "Initialize state branch with .gitignore"', { cwd: repoRoot });
11632
11746
  } catch (commitError) {
11633
11747
  const error = commitError;
11634
11748
  throw new Error(`Failed to create initial commit on orphan branch: ${error.stderr || error.message}`);
@@ -11683,6 +11797,17 @@ var SyncModule = class {
11683
11797
  );
11684
11798
  }
11685
11799
  }
11800
+ /**
11801
+ * [EARS-60] Detect file-level conflicts and identify remote-only changes.
11802
+ *
11803
+ * A conflict exists when:
11804
+ * 1. A file was modified by the remote during implicit pull
11805
+ * 2. AND the LOCAL USER also modified that same file (content in tempDir differs from what was in git before pull)
11806
+ *
11807
+ * This catches conflicts that git rebase can't detect because we copy files AFTER the pull.
11808
+ *
11809
+ * @param tempDir - Directory containing local .gitgov/ files (preserved before checkout)
11810
+ * @param repoRoot - Repository root path
11686
11811
  /**
11687
11812
  * Checks if a rebase is in progress.
11688
11813
  *
@@ -11817,8 +11942,14 @@ var SyncModule = class {
11817
11942
  const commit = commits[i];
11818
11943
  if (!commit) continue;
11819
11944
  const message = commit.message.toLowerCase();
11820
- const isRebaseCommit = message.includes("rebase") || message.includes("pick") || message.includes("conflict");
11821
- if (isRebaseCommit) {
11945
+ if (message.startsWith("resolution:")) {
11946
+ continue;
11947
+ }
11948
+ if (message.startsWith("sync:")) {
11949
+ continue;
11950
+ }
11951
+ const isExplicitRebaseCommit = message.includes("rebase") || message.includes("pick ");
11952
+ if (isExplicitRebaseCommit) {
11822
11953
  const nextCommit = commits[i + 1];
11823
11954
  const isResolutionNext = nextCommit && nextCommit.message.toLowerCase().startsWith("resolution:");
11824
11955
  if (!isResolutionNext) {
@@ -11963,14 +12094,34 @@ Then push your changes: git push -u origin ${sourceBranch}`
11963
12094
  log(`Audit result: ${auditReport.passed ? "PASSED" : "FAILED"}`);
11964
12095
  if (!auditReport.passed) {
11965
12096
  log(`Audit violations: ${auditReport.summary}`);
12097
+ const affectedFiles = [];
12098
+ const detailedErrors = [];
12099
+ if (auditReport.lintReport?.results) {
12100
+ for (const r of auditReport.lintReport.results) {
12101
+ if (r.level === "error") {
12102
+ if (!affectedFiles.includes(r.filePath)) {
12103
+ affectedFiles.push(r.filePath);
12104
+ }
12105
+ detailedErrors.push(` \u2022 ${r.filePath}: [${r.validator}] ${r.message}`);
12106
+ }
12107
+ }
12108
+ }
12109
+ for (const v of auditReport.integrityViolations) {
12110
+ detailedErrors.push(` \u2022 Commit ${v.rebaseCommitHash.slice(0, 8)}: ${v.commitMessage} (by ${v.author})`);
12111
+ }
12112
+ const detailSection = detailedErrors.length > 0 ? `
12113
+
12114
+ Details:
12115
+ ${detailedErrors.join("\n")}` : "";
11966
12116
  result.conflictDetected = true;
11967
12117
  result.conflictInfo = {
11968
12118
  type: "integrity_violation",
11969
- affectedFiles: [],
11970
- message: auditReport.summary,
12119
+ affectedFiles,
12120
+ message: auditReport.summary + detailSection,
11971
12121
  resolutionSteps: [
11972
- "Review audit violations",
11973
- "Fix integrity issues before pushing"
12122
+ "Run 'gitgov lint --fix' to auto-fix signature/checksum issues",
12123
+ "If issues persist, manually review the affected files",
12124
+ "Then retry: gitgov sync push"
11974
12125
  ]
11975
12126
  };
11976
12127
  result.error = "Integrity violations detected. Cannot push.";
@@ -12009,11 +12160,53 @@ Then push your changes: git push -u origin ${sourceBranch}`
12009
12160
  await this.git.checkoutBranch(savedBranch);
12010
12161
  if (tempDir) {
12011
12162
  try {
12012
- log("[EARS-47] Restoring .gitgov/ from temp directory (early return)...");
12013
12163
  const repoRoot2 = await this.git.getRepoRoot();
12014
12164
  const gitgovDir = path6__default.join(repoRoot2, ".gitgov");
12015
- await promises.cp(tempDir, gitgovDir, { recursive: true, force: true });
12016
- log("[EARS-47] .gitgov/ restored from temp (early return)");
12165
+ if (returnResult.implicitPull?.hasChanges) {
12166
+ log("[EARS-58] Implicit pull detected in early return - preserving new files from gitgov-state...");
12167
+ try {
12168
+ await this.git.checkoutFilesFromBranch(stateBranch, [".gitgov/"]);
12169
+ await execAsync("git reset HEAD .gitgov/ 2>/dev/null || true", { cwd: repoRoot2 });
12170
+ log("[EARS-58] Synced files copied from gitgov-state (unstaged)");
12171
+ } catch (checkoutError) {
12172
+ log(`[EARS-58] Warning: Failed to checkout from gitgov-state: ${checkoutError}`);
12173
+ }
12174
+ for (const fileName of LOCAL_ONLY_FILES) {
12175
+ const tempFilePath = path6__default.join(tempDir, fileName);
12176
+ const destFilePath = path6__default.join(gitgovDir, fileName);
12177
+ try {
12178
+ await promises.access(tempFilePath);
12179
+ await promises.cp(tempFilePath, destFilePath, { force: true });
12180
+ log(`[EARS-58] Restored LOCAL_ONLY_FILE: ${fileName}`);
12181
+ } catch {
12182
+ }
12183
+ }
12184
+ const restoreExcludedFilesEarly = async (dir, destDir) => {
12185
+ try {
12186
+ const entries = await promises.readdir(dir, { withFileTypes: true });
12187
+ for (const entry of entries) {
12188
+ const srcPath = path6__default.join(dir, entry.name);
12189
+ const dstPath = path6__default.join(destDir, entry.name);
12190
+ if (entry.isDirectory()) {
12191
+ await restoreExcludedFilesEarly(srcPath, dstPath);
12192
+ } else {
12193
+ const isExcluded = SYNC_EXCLUDED_PATTERNS.some((pattern) => pattern.test(entry.name));
12194
+ if (isExcluded) {
12195
+ await promises.mkdir(path6__default.dirname(dstPath), { recursive: true });
12196
+ await promises.copyFile(srcPath, dstPath);
12197
+ log(`[EARS-59] Restored excluded file (early return): ${entry.name}`);
12198
+ }
12199
+ }
12200
+ }
12201
+ } catch {
12202
+ }
12203
+ };
12204
+ await restoreExcludedFilesEarly(tempDir, gitgovDir);
12205
+ } else {
12206
+ log("[EARS-47] Restoring .gitgov/ from temp directory (early return)...");
12207
+ await promises.cp(tempDir, gitgovDir, { recursive: true, force: true });
12208
+ log("[EARS-47] .gitgov/ restored from temp (early return)");
12209
+ }
12017
12210
  await promises.rm(tempDir, { recursive: true, force: true });
12018
12211
  log("[EARS-47] Temp directory cleaned up (early return)");
12019
12212
  } catch (tempRestoreError) {
@@ -12030,42 +12223,32 @@ Then push your changes: git push -u origin ${sourceBranch}`
12030
12223
  returnResult.error = returnResult.error ? `${returnResult.error}. Failed to restore stashed changes.` : "Failed to restore stashed changes. Run 'git stash pop' manually.";
12031
12224
  }
12032
12225
  }
12226
+ if (returnResult.implicitPull?.hasChanges) {
12227
+ log("[EARS-58] Regenerating index after implicit pull (early return)...");
12228
+ try {
12229
+ await this.indexer.generateIndex();
12230
+ returnResult.implicitPull.reindexed = true;
12231
+ log("[EARS-58] Index regenerated successfully");
12232
+ } catch (indexError) {
12233
+ log(`[EARS-58] Warning: Failed to regenerate index: ${indexError}`);
12234
+ returnResult.implicitPull.reindexed = false;
12235
+ }
12236
+ }
12033
12237
  return returnResult;
12034
12238
  };
12035
12239
  log(`Checking out to ${stateBranch}...`);
12036
12240
  await this.git.checkoutBranch(stateBranch);
12037
12241
  log(`Now on branch: ${await this.git.getCurrentBranch()}`);
12038
- log("Attempting pull --rebase...");
12242
+ let filesBeforeChanges = /* @__PURE__ */ new Set();
12039
12243
  try {
12040
- await this.git.pullRebase("origin", stateBranch);
12041
- log("Pull rebase successful");
12042
- } catch (error) {
12043
- const errorMsg = error instanceof Error ? error.message : String(error);
12044
- log(`Pull rebase failed: ${errorMsg}`);
12045
- const isAlreadyUpToDate = errorMsg.includes("up to date") || errorMsg.includes("up-to-date");
12046
- const isNoRemote = errorMsg.includes("does not appear to be") || errorMsg.includes("Could not read from remote");
12047
- if (isAlreadyUpToDate || isNoRemote) {
12048
- log("Pull failed but continuing (already up-to-date or no remote)");
12049
- } else {
12050
- const conflictedFiles = await this.git.getConflictedFiles();
12051
- if (conflictedFiles.length > 0) {
12052
- await this.git.rebaseAbort();
12053
- result.conflictDetected = true;
12054
- result.conflictInfo = {
12055
- type: "rebase_conflict",
12056
- affectedFiles: conflictedFiles,
12057
- message: "Conflict detected during automatic reconciliation",
12058
- resolutionSteps: [
12059
- "Pull remote changes first: gitgov sync pull",
12060
- "Resolve any conflicts",
12061
- "Then push your changes"
12062
- ]
12063
- };
12064
- result.error = "Conflict detected during reconciliation";
12065
- return await restoreStashAndReturn(result);
12066
- }
12067
- throw error;
12068
- }
12244
+ const repoRoot2 = await this.git.getRepoRoot();
12245
+ const { stdout: filesOutput } = await execAsync(
12246
+ `git ls-files ".gitgov" 2>/dev/null || true`,
12247
+ { cwd: repoRoot2 }
12248
+ );
12249
+ filesBeforeChanges = new Set(filesOutput.trim().split("\n").filter((f) => f && shouldSyncFile(f)));
12250
+ log(`[EARS-57] Files in gitgov-state before changes: ${filesBeforeChanges.size}`);
12251
+ } catch {
12069
12252
  }
12070
12253
  log("=== Phase 2: Publication ===");
12071
12254
  log("Checking if .gitgov/ exists in gitgov-state...");
@@ -12213,6 +12396,43 @@ Files: ${delta.length} file(s) ${isFirstPush ? "synced (initial)" : "changed"}
12213
12396
  } catch {
12214
12397
  }
12215
12398
  log("[EARS-47] Non-syncable files cleanup complete");
12399
+ log("[EARS-57] Checking for deleted files to sync...");
12400
+ try {
12401
+ const sourceFiles = /* @__PURE__ */ new Set();
12402
+ const findSourceFiles = async (dir, prefix = ".gitgov") => {
12403
+ try {
12404
+ const entries = await promises.readdir(dir, { withFileTypes: true });
12405
+ for (const entry of entries) {
12406
+ const fullPath = path6__default.join(dir, entry.name);
12407
+ const relativePath = `${prefix}/${entry.name}`;
12408
+ if (entry.isDirectory()) {
12409
+ await findSourceFiles(fullPath, relativePath);
12410
+ } else if (shouldSyncFile(relativePath)) {
12411
+ sourceFiles.add(relativePath);
12412
+ }
12413
+ }
12414
+ } catch {
12415
+ }
12416
+ };
12417
+ const sourceDir = tempDir || path6__default.join(repoRoot, ".gitgov");
12418
+ await findSourceFiles(sourceDir);
12419
+ log(`[EARS-57] Found ${sourceFiles.size} syncable files in source (user's local state)`);
12420
+ log(`[EARS-57] Files that existed before changes: ${filesBeforeChanges.size}`);
12421
+ let deletedCount = 0;
12422
+ for (const fileBeforeChange of filesBeforeChanges) {
12423
+ if (!sourceFiles.has(fileBeforeChange)) {
12424
+ try {
12425
+ await execAsync(`git rm -f "${fileBeforeChange}"`, { cwd: repoRoot });
12426
+ log(`[EARS-57] Deleted (user removed): ${fileBeforeChange}`);
12427
+ deletedCount++;
12428
+ } catch {
12429
+ }
12430
+ }
12431
+ }
12432
+ log(`[EARS-57] Deleted ${deletedCount} files that user removed locally`);
12433
+ } catch (err) {
12434
+ log(`[EARS-57] Warning: Failed to sync deleted files: ${err}`);
12435
+ }
12216
12436
  const hasStaged = await this.git.hasUncommittedChanges();
12217
12437
  log(`Has staged changes: ${hasStaged}`);
12218
12438
  if (!hasStaged) {
@@ -12221,10 +12441,10 @@ Files: ${delta.length} file(s) ${isFirstPush ? "synced (initial)" : "changed"}
12221
12441
  result.filesSynced = 0;
12222
12442
  return await restoreStashAndReturn(result);
12223
12443
  }
12224
- log("Creating commit...");
12444
+ log("Creating local commit...");
12225
12445
  try {
12226
12446
  const commitHash = await this.git.commit(commitMessage);
12227
- log(`Commit created: ${commitHash}`);
12447
+ log(`Local commit created: ${commitHash}`);
12228
12448
  result.commitHash = commitHash;
12229
12449
  } catch (commitError) {
12230
12450
  const errorMsg = commitError instanceof Error ? commitError.message : String(commitError);
@@ -12242,6 +12462,116 @@ Files: ${delta.length} file(s) ${isFirstPush ? "synced (initial)" : "changed"}
12242
12462
  log(`ERROR: Git stderr: ${stderr}`);
12243
12463
  throw new Error(`Failed to create commit: ${errorMsg} | stderr: ${stderr}`);
12244
12464
  }
12465
+ log("=== Phase 3: Reconcile with Remote (Git-Native) ===");
12466
+ let hashBeforePull = null;
12467
+ try {
12468
+ const { stdout: beforeHash } = await execAsync(`git rev-parse HEAD`, { cwd: repoRoot });
12469
+ hashBeforePull = beforeHash.trim();
12470
+ log(`Hash before pull: ${hashBeforePull}`);
12471
+ } catch {
12472
+ }
12473
+ log("Attempting git pull --rebase origin gitgov-state...");
12474
+ try {
12475
+ await this.git.pullRebase("origin", stateBranch);
12476
+ log("Pull rebase successful - no conflicts");
12477
+ if (hashBeforePull) {
12478
+ try {
12479
+ const { stdout: afterHash } = await execAsync(`git rev-parse HEAD`, { cwd: repoRoot });
12480
+ const hashAfterPull = afterHash.trim();
12481
+ if (hashAfterPull !== hashBeforePull) {
12482
+ const pulledChangedFiles = await this.git.getChangedFiles(hashBeforePull, hashAfterPull, ".gitgov/");
12483
+ result.implicitPull = {
12484
+ hasChanges: true,
12485
+ filesUpdated: pulledChangedFiles.length,
12486
+ reindexed: false
12487
+ // Will be set to true after actual reindex
12488
+ };
12489
+ log(`[EARS-54] Implicit pull: ${pulledChangedFiles.length} files from remote were rebased`);
12490
+ }
12491
+ } catch (e) {
12492
+ log(`[EARS-54] Could not capture implicit pull details: ${e}`);
12493
+ }
12494
+ }
12495
+ } catch (pullError) {
12496
+ const errorMsg = pullError instanceof Error ? pullError.message : String(pullError);
12497
+ log(`Pull rebase result: ${errorMsg}`);
12498
+ const isAlreadyUpToDate = errorMsg.includes("up to date") || errorMsg.includes("up-to-date");
12499
+ const isNoRemote = errorMsg.includes("does not appear to be") || errorMsg.includes("Could not read from remote");
12500
+ const isNoUpstream = errorMsg.includes("no tracking information") || errorMsg.includes("There is no tracking information");
12501
+ if (isAlreadyUpToDate || isNoRemote || isNoUpstream) {
12502
+ log("Pull not needed or no remote - continuing to push");
12503
+ } else {
12504
+ const isRebaseInProgress = await this.isRebaseInProgress();
12505
+ const conflictedFiles = await this.git.getConflictedFiles();
12506
+ if (isRebaseInProgress || conflictedFiles.length > 0) {
12507
+ log(`[GIT-NATIVE] Conflict detected! Files: ${conflictedFiles.join(", ")}`);
12508
+ result.conflictDetected = true;
12509
+ const fileWord = conflictedFiles.length === 1 ? "file" : "files";
12510
+ const stageCommand = conflictedFiles.length === 1 ? `git add ${conflictedFiles[0]}` : "git add .gitgov/";
12511
+ result.conflictInfo = {
12512
+ type: "rebase_conflict",
12513
+ affectedFiles: conflictedFiles,
12514
+ message: "Conflict detected during sync - Git has paused the rebase for manual resolution",
12515
+ resolutionSteps: [
12516
+ `1. Edit the conflicted ${fileWord} to resolve conflicts (remove <<<<<<, ======, >>>>>> markers)`,
12517
+ `2. Stage resolved ${fileWord}: ${stageCommand}`,
12518
+ "3. Complete sync: gitgov sync resolve --reason 'your reason'",
12519
+ "(This will continue the rebase, re-sign the record, and return you to your original branch)"
12520
+ ]
12521
+ };
12522
+ result.error = `Conflict detected: ${conflictedFiles.length} file(s) need manual resolution. Use 'git status' to see details.`;
12523
+ if (stashHash) {
12524
+ try {
12525
+ await this.git.checkoutBranch(sourceBranch);
12526
+ await execAsync("git stash pop", { cwd: repoRoot });
12527
+ await this.git.checkoutBranch(stateBranch);
12528
+ log("Restored stash to original branch during conflict");
12529
+ } catch (stashErr) {
12530
+ log(`Warning: Could not restore stash: ${stashErr}`);
12531
+ }
12532
+ }
12533
+ if (tempDir) {
12534
+ log("Restoring local files (.key, .session.json, etc.) for conflict resolution...");
12535
+ const gitgovInState = path6__default.join(repoRoot, ".gitgov");
12536
+ for (const fileName of LOCAL_ONLY_FILES) {
12537
+ const srcPath = path6__default.join(tempDir, fileName);
12538
+ const destPath = path6__default.join(gitgovInState, fileName);
12539
+ try {
12540
+ await promises.access(srcPath);
12541
+ await promises.cp(srcPath, destPath, { force: true });
12542
+ log(`Restored LOCAL_ONLY_FILE for conflict resolution: ${fileName}`);
12543
+ } catch {
12544
+ }
12545
+ }
12546
+ const restoreExcluded = async (srcDir, destDir) => {
12547
+ try {
12548
+ const entries = await promises.readdir(srcDir, { withFileTypes: true });
12549
+ for (const entry of entries) {
12550
+ const srcPath = path6__default.join(srcDir, entry.name);
12551
+ const dstPath = path6__default.join(destDir, entry.name);
12552
+ if (entry.isDirectory()) {
12553
+ await restoreExcluded(srcPath, dstPath);
12554
+ } else {
12555
+ const isExcluded = SYNC_EXCLUDED_PATTERNS.some((pattern) => pattern.test(entry.name));
12556
+ if (isExcluded) {
12557
+ await promises.mkdir(path6__default.dirname(dstPath), { recursive: true });
12558
+ await promises.copyFile(srcPath, dstPath);
12559
+ log(`Restored EXCLUDED file for conflict resolution: ${entry.name}`);
12560
+ }
12561
+ }
12562
+ }
12563
+ } catch {
12564
+ }
12565
+ };
12566
+ await restoreExcluded(tempDir, gitgovInState);
12567
+ log("Local files restored for conflict resolution");
12568
+ }
12569
+ return result;
12570
+ }
12571
+ throw pullError;
12572
+ }
12573
+ }
12574
+ log("=== Phase 4: Push to Remote ===");
12245
12575
  log("Pushing to remote...");
12246
12576
  try {
12247
12577
  await this.git.push("origin", stateBranch);
@@ -12271,11 +12601,57 @@ Files: ${delta.length} file(s) ${isFirstPush ? "synced (initial)" : "changed"}
12271
12601
  }
12272
12602
  }
12273
12603
  if (tempDir) {
12274
- log("[EARS-43] Restoring ENTIRE .gitgov/ from temp directory to working tree...");
12275
12604
  const repoRoot2 = await this.git.getRepoRoot();
12276
12605
  const gitgovDir = path6__default.join(repoRoot2, ".gitgov");
12277
- await promises.cp(tempDir, gitgovDir, { recursive: true, force: true });
12278
- log("[EARS-43] Entire .gitgov/ restored from temp");
12606
+ if (result.implicitPull?.hasChanges) {
12607
+ log("[EARS-56] Implicit pull detected - copying synced files from gitgov-state first...");
12608
+ try {
12609
+ await this.git.checkoutFilesFromBranch(stateBranch, [".gitgov/"]);
12610
+ await execAsync("git reset HEAD .gitgov/ 2>/dev/null || true", { cwd: repoRoot2 });
12611
+ log("[EARS-56] Synced files copied from gitgov-state to work branch (unstaged)");
12612
+ } catch (checkoutError) {
12613
+ log(`[EARS-56] Warning: Failed to checkout from gitgov-state: ${checkoutError}`);
12614
+ await promises.cp(tempDir, gitgovDir, { recursive: true, force: true });
12615
+ log("[EARS-56] Fallback: Entire .gitgov/ restored from temp");
12616
+ }
12617
+ log("[EARS-56] Restoring local-only files from temp directory...");
12618
+ for (const fileName of LOCAL_ONLY_FILES) {
12619
+ const tempFilePath = path6__default.join(tempDir, fileName);
12620
+ const destFilePath = path6__default.join(gitgovDir, fileName);
12621
+ try {
12622
+ await promises.access(tempFilePath);
12623
+ await promises.cp(tempFilePath, destFilePath, { force: true });
12624
+ log(`[EARS-56] Restored LOCAL_ONLY_FILE: ${fileName}`);
12625
+ } catch {
12626
+ }
12627
+ }
12628
+ const restoreExcludedFiles = async (dir, destDir) => {
12629
+ try {
12630
+ const entries = await promises.readdir(dir, { withFileTypes: true });
12631
+ for (const entry of entries) {
12632
+ const srcPath = path6__default.join(dir, entry.name);
12633
+ const dstPath = path6__default.join(destDir, entry.name);
12634
+ if (entry.isDirectory()) {
12635
+ await restoreExcludedFiles(srcPath, dstPath);
12636
+ } else {
12637
+ const isExcluded = SYNC_EXCLUDED_PATTERNS.some((pattern) => pattern.test(entry.name));
12638
+ if (isExcluded) {
12639
+ await promises.mkdir(path6__default.dirname(dstPath), { recursive: true });
12640
+ await promises.copyFile(srcPath, dstPath);
12641
+ log(`[EARS-59] Restored excluded file: ${entry.name}`);
12642
+ }
12643
+ }
12644
+ }
12645
+ } catch {
12646
+ }
12647
+ };
12648
+ await restoreExcludedFiles(tempDir, gitgovDir);
12649
+ log("[EARS-59] Local-only and excluded files restored from temp");
12650
+ } else {
12651
+ log("[EARS-43] Restoring ENTIRE .gitgov/ from temp directory to working tree...");
12652
+ await promises.cp(tempDir, gitgovDir, { recursive: true, force: true });
12653
+ log("[EARS-43] Entire .gitgov/ restored from temp");
12654
+ }
12279
12655
  log("[EARS-43] Cleaning up temp directory...");
12280
12656
  try {
12281
12657
  await promises.rm(tempDir, { recursive: true, force: true });
@@ -12284,6 +12660,17 @@ Files: ${delta.length} file(s) ${isFirstPush ? "synced (initial)" : "changed"}
12284
12660
  log(`[EARS-43] Warning: Failed to cleanup temp directory: ${cleanupError}`);
12285
12661
  }
12286
12662
  }
12663
+ if (result.implicitPull?.hasChanges) {
12664
+ log("[EARS-54] Regenerating index after implicit pull...");
12665
+ try {
12666
+ await this.indexer.generateIndex();
12667
+ result.implicitPull.reindexed = true;
12668
+ log("[EARS-54] Index regenerated successfully after implicit pull");
12669
+ } catch (indexError) {
12670
+ log(`[EARS-54] Warning: Failed to regenerate index after implicit pull: ${indexError}`);
12671
+ result.implicitPull.reindexed = false;
12672
+ }
12673
+ }
12287
12674
  result.success = true;
12288
12675
  result.filesSynced = delta.length;
12289
12676
  log(`=== pushState COMPLETED SUCCESSFULLY: ${delta.length} files synced ===`);
@@ -12326,7 +12713,7 @@ Files: ${delta.length} file(s) ${isFirstPush ? "synced (initial)" : "changed"}
12326
12713
  * [EARS-44] Requires remote to be configured (pull without remote makes no sense)
12327
12714
  */
12328
12715
  async pullState(options = {}) {
12329
- const { forceReindex = false } = options;
12716
+ const { forceReindex = false, force = false } = options;
12330
12717
  const stateBranch = await this.getStateBranchName();
12331
12718
  if (!stateBranch) {
12332
12719
  throw new SyncError("Failed to get state branch name");
@@ -12393,6 +12780,37 @@ Files: ${delta.length} file(s) ${isFirstPush ? "synced (initial)" : "changed"}
12393
12780
  } catch (error) {
12394
12781
  log(`[EARS-51] Warning: Could not save local files: ${error.message}`);
12395
12782
  }
12783
+ const savedSyncableFiles = /* @__PURE__ */ new Map();
12784
+ try {
12785
+ const gitgovPath2 = path6__default.join(pullRepoRoot, ".gitgov");
12786
+ const gitgovExists2 = await promises.access(gitgovPath2).then(() => true).catch(() => false);
12787
+ if (gitgovExists2) {
12788
+ const readSyncableFilesRecursive = async (dir, baseDir) => {
12789
+ try {
12790
+ const entries = await promises.readdir(dir, { withFileTypes: true });
12791
+ for (const entry of entries) {
12792
+ const fullPath = path6__default.join(dir, entry.name);
12793
+ const relativePath = path6__default.relative(baseDir, fullPath);
12794
+ const gitgovRelativePath = `.gitgov/${relativePath}`;
12795
+ if (entry.isDirectory()) {
12796
+ await readSyncableFilesRecursive(fullPath, baseDir);
12797
+ } else if (shouldSyncFile(gitgovRelativePath)) {
12798
+ try {
12799
+ const content = await promises.readFile(fullPath, "utf-8");
12800
+ savedSyncableFiles.set(gitgovRelativePath, content);
12801
+ } catch {
12802
+ }
12803
+ }
12804
+ }
12805
+ } catch {
12806
+ }
12807
+ };
12808
+ await readSyncableFilesRecursive(gitgovPath2, gitgovPath2);
12809
+ log(`[EARS-61] Saved ${savedSyncableFiles.size} syncable files before checkout for conflict detection`);
12810
+ }
12811
+ } catch (error) {
12812
+ log(`[EARS-61] Warning: Could not save syncable files: ${error.message}`);
12813
+ }
12396
12814
  try {
12397
12815
  await this.git.checkoutBranch(stateBranch);
12398
12816
  } catch (checkoutError) {
@@ -12427,6 +12845,90 @@ Files: ${delta.length} file(s) ${isFirstPush ? "synced (initial)" : "changed"}
12427
12845
  });
12428
12846
  const hashBefore = commitBefore[0]?.hash;
12429
12847
  await this.git.fetch("origin");
12848
+ log("[EARS-61] Checking for local changes that would be overwritten...");
12849
+ let remoteChangedFiles = [];
12850
+ try {
12851
+ const { stdout: remoteChanges } = await execAsync(
12852
+ `git diff --name-only ${stateBranch} origin/${stateBranch} -- .gitgov/ 2>/dev/null || true`,
12853
+ { cwd: pullRepoRoot }
12854
+ );
12855
+ remoteChangedFiles = remoteChanges.trim().split("\n").filter((f) => f && shouldSyncFile(f));
12856
+ log(`[EARS-61] Remote changed files: ${remoteChangedFiles.length} - ${remoteChangedFiles.join(", ")}`);
12857
+ } catch {
12858
+ log("[EARS-61] Could not determine remote changes, continuing...");
12859
+ }
12860
+ let localModifiedFiles = [];
12861
+ if (remoteChangedFiles.length > 0 && savedSyncableFiles.size > 0) {
12862
+ try {
12863
+ for (const remoteFile of remoteChangedFiles) {
12864
+ const savedContent = savedSyncableFiles.get(remoteFile);
12865
+ if (savedContent !== void 0) {
12866
+ try {
12867
+ const { stdout: gitStateContent } = await execAsync(
12868
+ `git show HEAD:${remoteFile} 2>/dev/null`,
12869
+ { cwd: pullRepoRoot }
12870
+ );
12871
+ if (savedContent !== gitStateContent) {
12872
+ localModifiedFiles.push(remoteFile);
12873
+ log(`[EARS-61] Local file was modified since last sync: ${remoteFile}`);
12874
+ }
12875
+ } catch {
12876
+ localModifiedFiles.push(remoteFile);
12877
+ log(`[EARS-61] Local file is new (not in gitgov-state): ${remoteFile}`);
12878
+ }
12879
+ }
12880
+ }
12881
+ log(`[EARS-61] Local modified files that overlap with remote: ${localModifiedFiles.length}`);
12882
+ } catch (error) {
12883
+ log(`[EARS-61] Warning: Could not check local modifications: ${error.message}`);
12884
+ }
12885
+ }
12886
+ if (localModifiedFiles.length > 0) {
12887
+ if (force) {
12888
+ log(`[EARS-62] Force flag set - will overwrite ${localModifiedFiles.length} local file(s)`);
12889
+ logger6.warn(`[pullState] Force pull: overwriting local changes to ${localModifiedFiles.length} file(s)`);
12890
+ result.forcedOverwrites = localModifiedFiles;
12891
+ } else {
12892
+ log(`[EARS-61] CONFLICT: Local changes would be overwritten by pull`);
12893
+ await this.git.checkoutBranch(savedBranch);
12894
+ for (const [filePath, content] of savedSyncableFiles) {
12895
+ const fullPath = path6__default.join(pullRepoRoot, filePath);
12896
+ try {
12897
+ await promises.mkdir(path6__default.dirname(fullPath), { recursive: true });
12898
+ await promises.writeFile(fullPath, content, "utf-8");
12899
+ log(`[EARS-61] Restored syncable file: ${filePath}`);
12900
+ } catch {
12901
+ }
12902
+ }
12903
+ for (const [fileName, content] of savedLocalFiles) {
12904
+ const filePath = path6__default.join(pullRepoRoot, ".gitgov", fileName);
12905
+ try {
12906
+ await promises.mkdir(path6__default.dirname(filePath), { recursive: true });
12907
+ await promises.writeFile(filePath, content, "utf-8");
12908
+ log(`[EARS-61] Restored local-only file: ${fileName}`);
12909
+ } catch {
12910
+ }
12911
+ }
12912
+ result.success = false;
12913
+ result.conflictDetected = true;
12914
+ result.conflictInfo = {
12915
+ type: "local_changes_conflict",
12916
+ affectedFiles: localModifiedFiles,
12917
+ message: `Your local changes to the following files would be overwritten by pull.
12918
+ You have modified these files locally, and they were also modified remotely.
12919
+ To avoid losing your changes, push first or use --force to overwrite.`,
12920
+ resolutionSteps: [
12921
+ "1. Run 'gitgov sync push' to push your local changes first",
12922
+ " \u2192 This will trigger a rebase and let you resolve conflicts properly",
12923
+ "2. Or run 'gitgov sync pull --force' to discard your local changes"
12924
+ ]
12925
+ };
12926
+ result.error = "Aborting pull: local changes would be overwritten by remote changes";
12927
+ logger6.warn(`[pullState] Aborting: local changes to ${localModifiedFiles.length} file(s) would be overwritten by pull`);
12928
+ return result;
12929
+ }
12930
+ }
12931
+ log("[EARS-61] No conflicting local changes (or force enabled), proceeding with pull...");
12430
12932
  try {
12431
12933
  await this.git.pullRebase("origin", stateBranch);
12432
12934
  } catch (error) {
@@ -12457,7 +12959,10 @@ Files: ${delta.length} file(s) ${isFirstPush ? "synced (initial)" : "changed"}
12457
12959
  const hashAfter = commitAfter[0]?.hash;
12458
12960
  const hasNewChanges = hashBefore !== hashAfter;
12459
12961
  result.hasChanges = hasNewChanges;
12460
- if (hasNewChanges || forceReindex) {
12962
+ const indexPath = path6__default.join(pullRepoRoot, ".gitgov", "index.json");
12963
+ const indexExists = await promises.access(indexPath).then(() => true).catch(() => false);
12964
+ const shouldReindex = hasNewChanges || forceReindex || !indexExists;
12965
+ if (shouldReindex) {
12461
12966
  result.reindexed = true;
12462
12967
  if (hasNewChanges && hashBefore && hashAfter) {
12463
12968
  const changedFiles = await this.git.getChangedFiles(
@@ -12467,13 +12972,6 @@ Files: ${delta.length} file(s) ${isFirstPush ? "synced (initial)" : "changed"}
12467
12972
  );
12468
12973
  result.filesUpdated = changedFiles.length;
12469
12974
  }
12470
- logger6.info("Invoking IndexerAdapter.generateIndex() after pull...");
12471
- try {
12472
- await this.indexer.generateIndex();
12473
- logger6.info("Index regenerated successfully");
12474
- } catch (error) {
12475
- logger6.warn(`Failed to regenerate index: ${error.message}`);
12476
- }
12477
12975
  }
12478
12976
  const gitgovPath = path6__default.join(pullRepoRoot, ".gitgov");
12479
12977
  const gitgovExists = await promises.access(gitgovPath).then(() => true).catch(() => false);
@@ -12518,6 +13016,15 @@ Files: ${delta.length} file(s) ${isFirstPush ? "synced (initial)" : "changed"}
12518
13016
  logger6.warn(`[pullState] Failed to restore .gitgov/ to filesystem: ${error.message}`);
12519
13017
  }
12520
13018
  }
13019
+ if (shouldReindex) {
13020
+ logger6.info("Invoking IndexerAdapter.generateIndex() after pull...");
13021
+ try {
13022
+ await this.indexer.generateIndex();
13023
+ logger6.info("Index regenerated successfully");
13024
+ } catch (error) {
13025
+ logger6.warn(`Failed to regenerate index: ${error.message}`);
13026
+ }
13027
+ }
12521
13028
  result.success = true;
12522
13029
  log(`=== pullState COMPLETED: ${hasNewChanges ? "new changes pulled" : "no changes"}, reindexed: ${result.reindexed} ===`);
12523
13030
  return result;
@@ -12531,25 +13038,37 @@ Files: ${delta.length} file(s) ${isFirstPush ? "synced (initial)" : "changed"}
12531
13038
  }
12532
13039
  }
12533
13040
  /**
12534
- * Resolves state conflicts in a governed manner.
12535
- * Updates resolved Records (recalculates checksum and adds resolver signature),
12536
- * creates rebase and resolution commits signed according to protocol.
13041
+ * Resolves state conflicts in a governed manner (Git-Native).
13042
+ *
13043
+ * Git-Native Flow:
13044
+ * 1. User resolves conflicts using standard Git tools (edit files, remove markers)
13045
+ * 2. User stages resolved files: git add .gitgov/
13046
+ * 3. User runs: gitgov sync resolve --reason "reason"
13047
+ *
13048
+ * This method:
13049
+ * - Verifies that a rebase is in progress
13050
+ * - Checks that no conflict markers remain in staged files
13051
+ * - Updates resolved Records with new checksums and signatures
13052
+ * - Continues the git rebase (git rebase --continue)
13053
+ * - Creates a signed resolution commit
13054
+ * - Regenerates the index
12537
13055
  *
12538
13056
  * [EARS-17 through EARS-23]
12539
13057
  */
12540
13058
  async resolveConflict(options) {
12541
13059
  const { reason, actorId } = options;
12542
13060
  const log = (msg) => logger6.debug(`[resolveConflict] ${msg}`);
12543
- log("=== STARTING resolveConflict ===");
12544
- log("Phase 0: Verifying rebase state...");
13061
+ log("=== STARTING resolveConflict (Git-Native) ===");
13062
+ log("Phase 0: Verifying rebase in progress...");
12545
13063
  const rebaseInProgress = await this.isRebaseInProgress();
12546
13064
  if (!rebaseInProgress) {
12547
13065
  throw new NoRebaseInProgressError();
12548
13066
  }
13067
+ log("Conflict mode: rebase_conflict (Git-Native)");
12549
13068
  console.log("[resolveConflict] Getting staged files...");
12550
13069
  const allStagedFiles = await this.git.getStagedFiles();
12551
13070
  console.log("[resolveConflict] All staged files:", allStagedFiles);
12552
- const resolvedRecords = allStagedFiles.filter(
13071
+ let resolvedRecords = allStagedFiles.filter(
12553
13072
  (f) => f.startsWith(".gitgov/") && f.endsWith(".json")
12554
13073
  );
12555
13074
  console.log("[resolveConflict] Resolved Records (staged .gitgov/*.json):", resolvedRecords);
@@ -12559,7 +13078,33 @@ Files: ${delta.length} file(s) ${isFirstPush ? "synced (initial)" : "changed"}
12559
13078
  if (filesWithMarkers.length > 0) {
12560
13079
  throw new ConflictMarkersPresentError(filesWithMarkers);
12561
13080
  }
12562
- console.log("[resolveConflict] Updating resolved Records...");
13081
+ let rebaseCommitHash = "";
13082
+ console.log("[resolveConflict] Step 4: Calling git.rebaseContinue()...");
13083
+ await this.git.rebaseContinue();
13084
+ console.log("[resolveConflict] rebaseContinue completed successfully");
13085
+ const currentBranch = await this.git.getCurrentBranch();
13086
+ const rebaseCommit = await this.git.getCommitHistory(currentBranch, {
13087
+ maxCount: 1
13088
+ });
13089
+ rebaseCommitHash = rebaseCommit[0]?.hash ?? "";
13090
+ if (resolvedRecords.length === 0 && rebaseCommitHash) {
13091
+ console.log("[resolveConflict] No staged files detected, getting files from rebase commit...");
13092
+ const repoRoot2 = await this.git.getRepoRoot();
13093
+ try {
13094
+ const { stdout } = await execAsync(
13095
+ `git diff-tree --no-commit-id --name-only -r ${rebaseCommitHash}`,
13096
+ { cwd: repoRoot2 }
13097
+ );
13098
+ const commitFiles = stdout.trim().split("\n").filter((f) => f);
13099
+ resolvedRecords = commitFiles.filter(
13100
+ (f) => f.startsWith(".gitgov/") && f.endsWith(".json")
13101
+ );
13102
+ console.log("[resolveConflict] Files from rebase commit:", resolvedRecords);
13103
+ } catch (e) {
13104
+ console.log("[resolveConflict] Could not get files from rebase commit:", e);
13105
+ }
13106
+ }
13107
+ console.log("[resolveConflict] Updating resolved Records with signatures...");
12563
13108
  if (resolvedRecords.length > 0) {
12564
13109
  const currentActor = await this.identity.getCurrentActor();
12565
13110
  console.log("[resolveConflict] Current actor:", currentActor);
@@ -12567,8 +13112,8 @@ Files: ${delta.length} file(s) ${isFirstPush ? "synced (initial)" : "changed"}
12567
13112
  for (const filePath of resolvedRecords) {
12568
13113
  console.log("[resolveConflict] Processing Record:", filePath);
12569
13114
  try {
12570
- const repoRoot = await this.git.getRepoRoot();
12571
- const fullPath = join(repoRoot, filePath);
13115
+ const repoRoot2 = await this.git.getRepoRoot();
13116
+ const fullPath = join(repoRoot2, filePath);
12572
13117
  const content = readFileSync(fullPath, "utf-8");
12573
13118
  const record = JSON.parse(content);
12574
13119
  if (!record.header || !record.payload) {
@@ -12577,7 +13122,8 @@ Files: ${delta.length} file(s) ${isFirstPush ? "synced (initial)" : "changed"}
12577
13122
  const signedRecord = await this.identity.signRecord(
12578
13123
  record,
12579
13124
  currentActor.id,
12580
- "resolver"
13125
+ "resolver",
13126
+ `Conflict resolved: ${reason}`
12581
13127
  );
12582
13128
  writeFileSync(fullPath, JSON.stringify(signedRecord, null, 2) + "\n", "utf-8");
12583
13129
  logger6.info(`Updated Record: ${filePath} (new checksum + resolver signature)`);
@@ -12587,20 +13133,12 @@ Files: ${delta.length} file(s) ${isFirstPush ? "synced (initial)" : "changed"}
12587
13133
  console.log("[resolveConflict] Error updating Record:", filePath, error);
12588
13134
  }
12589
13135
  }
12590
- console.log("[resolveConflict] All Records updated, re-staging...");
13136
+ console.log("[resolveConflict] All Records updated, staging...");
12591
13137
  }
12592
- console.log("[resolveConflict] Re-staging .gitgov/ with updated metadata...");
13138
+ console.log("[resolveConflict] Staging .gitgov/ with updated metadata...");
12593
13139
  await this.git.add([".gitgov"], { force: true });
12594
- console.log("[resolveConflict] Step 6: Calling git.rebaseContinue() (THIS MAY HANG)...");
12595
- await this.git.rebaseContinue();
12596
- console.log("[resolveConflict] rebaseContinue completed successfully");
12597
- const currentBranch = await this.git.getCurrentBranch();
12598
- const rebaseCommit = await this.git.getCommitHistory(currentBranch, {
12599
- maxCount: 1
12600
- });
12601
- const rebaseCommitHash = rebaseCommit[0]?.hash ?? "";
12602
13140
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
12603
- const resolutionMessage = `resolution: Conflict resolved by ${actorId}
13141
+ const resolutionMessage = `resolution: conflict resolved by ${actorId}
12604
13142
 
12605
13143
  Actor: ${actorId}
12606
13144
  Timestamp: ${timestamp}
@@ -12608,9 +13146,116 @@ Reason: ${reason}
12608
13146
  Files: ${resolvedRecords.length} file(s) resolved
12609
13147
 
12610
13148
  Signed-off-by: ${actorId}`;
12611
- const resolutionCommitHash = await this.git.commitAllowEmpty(
12612
- resolutionMessage
12613
- );
13149
+ let resolutionCommitHash = "";
13150
+ try {
13151
+ resolutionCommitHash = await this.git.commit(resolutionMessage);
13152
+ } catch (commitError) {
13153
+ const stdout = commitError.stdout || "";
13154
+ const stderr = commitError.stderr || "";
13155
+ const isNothingToCommit = stdout.includes("nothing to commit") || stderr.includes("nothing to commit") || stdout.includes("nothing added to commit") || stderr.includes("nothing added to commit");
13156
+ if (isNothingToCommit) {
13157
+ log("No additional changes to commit (no records needed re-signing)");
13158
+ resolutionCommitHash = rebaseCommitHash;
13159
+ } else {
13160
+ throw commitError;
13161
+ }
13162
+ }
13163
+ log("Pushing resolved state to remote...");
13164
+ try {
13165
+ await this.git.push("origin", "gitgov-state");
13166
+ log("Push successful");
13167
+ } catch (pushError) {
13168
+ const pushErrorMsg = pushError instanceof Error ? pushError.message : String(pushError);
13169
+ log(`Push failed (non-fatal): ${pushErrorMsg}`);
13170
+ }
13171
+ log("Returning to original branch and restoring .gitgov/ files...");
13172
+ const repoRoot = await this.git.getRepoRoot();
13173
+ const gitgovDir = path6__default.join(repoRoot, ".gitgov");
13174
+ const tempDir = path6__default.join(os.tmpdir(), `gitgov-resolve-${Date.now()}`);
13175
+ await promises.mkdir(tempDir, { recursive: true });
13176
+ log(`Created temp directory for local files: ${tempDir}`);
13177
+ for (const fileName of LOCAL_ONLY_FILES) {
13178
+ const srcPath = path6__default.join(gitgovDir, fileName);
13179
+ const destPath = path6__default.join(tempDir, fileName);
13180
+ try {
13181
+ await promises.access(srcPath);
13182
+ await promises.cp(srcPath, destPath, { force: true });
13183
+ log(`Saved LOCAL_ONLY_FILE to temp: ${fileName}`);
13184
+ } catch {
13185
+ }
13186
+ }
13187
+ const saveExcludedFiles = async (srcDir, destDir) => {
13188
+ try {
13189
+ const entries = await promises.readdir(srcDir, { withFileTypes: true });
13190
+ for (const entry of entries) {
13191
+ const srcPath = path6__default.join(srcDir, entry.name);
13192
+ const dstPath = path6__default.join(destDir, entry.name);
13193
+ if (entry.isDirectory()) {
13194
+ await promises.mkdir(dstPath, { recursive: true });
13195
+ await saveExcludedFiles(srcPath, dstPath);
13196
+ } else {
13197
+ const isExcluded = SYNC_EXCLUDED_PATTERNS.some((pattern) => pattern.test(entry.name));
13198
+ if (isExcluded) {
13199
+ await promises.mkdir(path6__default.dirname(dstPath), { recursive: true });
13200
+ await promises.copyFile(srcPath, dstPath);
13201
+ log(`Saved EXCLUDED file to temp: ${entry.name}`);
13202
+ }
13203
+ }
13204
+ }
13205
+ } catch {
13206
+ }
13207
+ };
13208
+ await saveExcludedFiles(gitgovDir, tempDir);
13209
+ try {
13210
+ await execAsync("git checkout -", { cwd: repoRoot });
13211
+ log("Returned to original branch");
13212
+ } catch (checkoutError) {
13213
+ log(`Warning: Could not return to original branch: ${checkoutError}`);
13214
+ }
13215
+ log("Restoring .gitgov/ from gitgov-state...");
13216
+ try {
13217
+ await this.git.checkoutFilesFromBranch("gitgov-state", [".gitgov/"]);
13218
+ await execAsync("git reset HEAD .gitgov/ 2>/dev/null || true", { cwd: repoRoot });
13219
+ log("Restored .gitgov/ from gitgov-state (unstaged)");
13220
+ } catch (checkoutFilesError) {
13221
+ log(`Warning: Could not restore .gitgov/ from gitgov-state: ${checkoutFilesError}`);
13222
+ }
13223
+ for (const fileName of LOCAL_ONLY_FILES) {
13224
+ const srcPath = path6__default.join(tempDir, fileName);
13225
+ const destPath = path6__default.join(gitgovDir, fileName);
13226
+ try {
13227
+ await promises.access(srcPath);
13228
+ await promises.cp(srcPath, destPath, { force: true });
13229
+ log(`Restored LOCAL_ONLY_FILE from temp: ${fileName}`);
13230
+ } catch {
13231
+ }
13232
+ }
13233
+ const restoreExcludedFiles = async (srcDir, destDir) => {
13234
+ try {
13235
+ const entries = await promises.readdir(srcDir, { withFileTypes: true });
13236
+ for (const entry of entries) {
13237
+ const srcPath = path6__default.join(srcDir, entry.name);
13238
+ const dstPath = path6__default.join(destDir, entry.name);
13239
+ if (entry.isDirectory()) {
13240
+ await restoreExcludedFiles(srcPath, dstPath);
13241
+ } else {
13242
+ const isExcluded = SYNC_EXCLUDED_PATTERNS.some((pattern) => pattern.test(entry.name));
13243
+ if (isExcluded) {
13244
+ await promises.mkdir(path6__default.dirname(dstPath), { recursive: true });
13245
+ await promises.copyFile(srcPath, dstPath);
13246
+ log(`Restored EXCLUDED file from temp: ${entry.name}`);
13247
+ }
13248
+ }
13249
+ }
13250
+ } catch {
13251
+ }
13252
+ };
13253
+ await restoreExcludedFiles(tempDir, gitgovDir);
13254
+ try {
13255
+ await promises.rm(tempDir, { recursive: true, force: true });
13256
+ log("Temp directory cleaned up");
13257
+ } catch {
13258
+ }
12614
13259
  logger6.info("Invoking IndexerAdapter.generateIndex() after conflict resolution...");
12615
13260
  try {
12616
13261
  await this.indexer.generateIndex();