Skip to content

[Bug]: Commit silently skips a resolved merge when its tree matches HEAD #35749

Closed

Description

@coygeek

Summary

Commit reports success without completing a merge when the user resolves every conflict by keeping the current branch's contents. The merge remains in progress even though the same resolution commits normally in Git. This was reproduced through the real GitManager action with disposable repositories; a nearby resolution that changes the tree succeeds through the same action.

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Create main and topic branches that change the same file differently, then run git merge topic on main to produce a content conflict.
  2. Edit the conflicted file to keep exactly the current branch's contents, removing all conflict markers. Leave the index unmerged, as T3's Commit action stages the resolved files itself.
  3. Invoke the Commit action with a custom message. It returns skipped_no_changes and Done, leaving the merge pending.
  4. In the same failing fixture, git add -A followed by git commit -m "Complete merge" succeeds with two parents. In a separate control fixture, a resolution combining both branches' contents succeeds through T3's action.

The initial driver probe, the full-action probe, and the final script all reproduced the failure. The changed-resolution control and direct-Git control passed. No running client was launched; UI reachability follows the enabled Commit action for the unmerged file and the ordinary stacked-action RPC path.

Save the script as repro-merge.ts outside the checkout and run node repro-merge.ts /path/to/t3code. It requires Node with TypeScript stripping, Git, and the checkout's installed lockfile dependencies. It creates repositories, HOME, TMPDIR, and T3 state under a temporary directory, removes its fixtures on exit, makes no network requests, and leaves the checkout unchanged. Exit 0 means both resolutions create completed merge commits, 1 means the defect reproduced, and 2 means setup or a control failed.

Reproduction script: repro-merge.ts
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir, release } from "node:os";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import { execFileSync } from "node:child_process";

const checkout = resolve(process.argv[2] ?? ".");
const scratch = mkdtempSync(join(tmpdir(), "t3-merge-repro-"));
for (const name of Object.keys(process.env)) {
  if (name.startsWith("GIT_")) delete process.env[name];
}
for (const [name, folder] of [["HOME", "home"], ["TMPDIR", "tmp"], ["T3CODE_HOME", "state"]]) {
  process.env[name] = join(scratch, folder);
  mkdirSync(process.env[name]!, { recursive: true });
}
process.env.GIT_CONFIG_NOSYSTEM = "1";
process.env.GIT_CONFIG_GLOBAL = "/dev/null";
const req = createRequire(join(checkout, "apps/server/package.json"));
const load = (name: string) => import(pathToFileURL(req.resolve(name)).href);
const source = (name: string) => import(pathToFileURL(join(checkout, name)).href);
const git = (cwd: string, ...args: string[]) => execFileSync("git", args, {
  cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
}).trim();
let status = 2;
try {
  console.log(git(checkout, "--version"));
  console.log(`node ${process.versions.node}; ${process.platform} ${release()} ${process.arch}`);
  console.log(`checkout ${git(checkout, "rev-parse", "HEAD")}`);
  const [Effect, Layer, NodeServices, NodeCrypto, Config, Core, Manager, Driver, Providers, Text, Registry, Setup, Settings, Projection, Projects] = await Promise.all([
    load("effect/Effect"), load("effect/Layer"), load("@effect/platform-node/NodeServices"), load("@effect/platform-node/NodeCrypto"),
    source("apps/server/src/config.ts"), source("apps/server/src/vcs/GitVcsDriverCore.ts"), source("apps/server/src/git/GitManager.ts"), source("apps/server/src/vcs/GitVcsDriver.ts"),
    source("apps/server/src/sourceControl/SourceControlProviderRegistry.ts"), source("apps/server/src/textGeneration/TextGeneration.ts"), source("apps/server/src/provider/ProviderRegistry.ts"),
    source("apps/server/src/project/ProjectSetupScriptRunner.ts"), source("apps/server/src/serverSettings.ts"), source("apps/server/src/orchestration-v2/ProjectionStore.ts"), source("apps/server/src/orchestration-v2/ProjectStore.ts"),
  ]);
  function fixture(name: string, resolution: string) {
    const cwd = join(scratch, name);
    mkdirSync(cwd);
    git(cwd, "init", "-q", "-b", "main");
    git(cwd, "config", "user.name", "Test");
    git(cwd, "config", "user.email", "[email protected]");
    writeFileSync(join(cwd, "note.txt"), "base\n");
    git(cwd, "add", "."); git(cwd, "commit", "-qm", "Base");
    git(cwd, "switch", "-qc", "topic");
    writeFileSync(join(cwd, "note.txt"), "topic\n"); git(cwd, "commit", "-qam", "Topic");
    git(cwd, "switch", "-q", "main");
    writeFileSync(join(cwd, "note.txt"), "main\n"); git(cwd, "commit", "-qam", "Main");
    try { git(cwd, "merge", "topic"); } catch { /* Expected conflict. */ }
    if (!existsSync(join(cwd, ".git/MERGE_HEAD"))) throw new Error("Fixture has no merge");
    writeFileSync(join(cwd, "note.txt"), resolution);
    return cwd;
  }
  async function commit(cwd: string) {
    return Effect.runPromise(Effect.scoped(Effect.gen(function* () {
      const driver = yield* Core.makeGitVcsDriverCore();
      const dependencies = Layer.mergeAll(
        Layer.succeed(Driver.GitVcsDriver, driver),
        Layer.mock(Providers.SourceControlProviderRegistry)({ resolve: () => Effect.fail(new Error("No host in fixture")) }),
        Layer.mock(Text.TextGeneration)({}), Layer.mock(Registry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
        Layer.mock(Setup.ProjectSetupScriptRunner)({}), Settings.layerTest(),
        Layer.mock(Projection.ProjectionStoreV2)({}), Layer.mock(Projects.ProjectStoreV2)({}),
      );
      const manager = yield* Manager.make.pipe(Effect.provide(dependencies));
      return yield* manager.runStackedAction({ cwd, action: "commit", commitMessage: "Complete merge" });
    })).pipe(Effect.provide(Config.layerTest(cwd, join(scratch, "server"))), Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))));
  }
  const parents = (cwd: string) => git(cwd, "show", "-s", "--format=%P", "HEAD").split(" ").length;
  const control = fixture("changed-resolution", "main\ntopic\n");
  const controlResult = await commit(control);
  if (controlResult.commit.status !== "created" || parents(control) !== 2 || existsSync(join(control, ".git/MERGE_HEAD"))) throw new Error("Changed-resolution control failed");
  console.log("control: created merge with two parents");
  const cwd = fixture("unchanged-resolution", "main\n");
  console.log(`before: ${git(cwd, "status", "--porcelain")}`);
  const result = await commit(cwd);
  console.log(`action: ${result.commit.status}; toast: ${result.toast.title}`);
  const pending = existsSync(join(cwd, ".git/MERGE_HEAD"));
  console.log(`merge still pending: ${pending}`);
  if (result.commit.status === "created" && parents(cwd) === 2 && !pending) {
    status = 0;
  } else if (result.commit.status === "skipped_no_changes" && pending) {
    git(cwd, "add", "-A"); git(cwd, "commit", "-qm", "Complete merge");
    if (parents(cwd) !== 2) throw new Error("Git control failed");
    console.log("Git control: created merge with two parents");
    status = 1;
  }
} catch (error) {
  const message = String(error).replaceAll(scratch, "<scratch>").replaceAll(checkout, "<checkout>");
  console.log(`setup or control failed: ${message}`);
} finally {
  rmSync(scratch, { recursive: true, force: true });
}
console.log(status === 0 ? "RESULT: expected behavior" : status === 1 ? "RESULT: defect reproduced" : "RESULT: inconclusive");
process.exit(status);

Expected behavior

Commit completes the resolved merge, records both parents, and clears the merge state even if the resulting tree matches the first parent's tree. Git's true-merge contract requires the merge commit to tie the parents together, and its conflict-resolution contract says git commit completes a merge after staging its resolution. T3's own all-files guidance explicitly tells users to commit all files to finish a merge at apps/server/src/vcs/GitVcsDriverCore.ts#L2522-L2541. The direct-Git control establishes this behavior for the exact same resolved tree without --allow-empty.

Actual behavior

After a content conflict is resolved to the current branch's version, GitManager.runStackedAction({ cwd, action: "commit", commitMessage: "Complete merge" }) returns commit.status: "skipped_no_changes" and a Done toast. MERGE_HEAD remains and no merge commit is created. The real Git driver and action service were executed; unrelated provider and projection services use the repository's Layer.mock helper and no AI provider or network is called.

Impact

Minor bug or occasional failure

Impact: P2 - A valid resolved merge cannot be completed through Commit, which reports Done while leaving the repository in the merge state; completing it in a terminal is a safe verified workaround.

The reproduced input resolves the only conflicting file to the current branch's contents. A resolution that changes the tree works. The failure preserves the user's file contents and merge state, so no loss of repository data was observed. The service reproduction ran on macOS; packaged desktop and mobile clients were not run.

Version or commit

main @ 0bb6eaa

Environment

Node 26.12.1, Git 2.56.0, Darwin 27.2.0, arm64.

Logs or stack traces

Evidence

Actual final script output, exit 1:

git version 2.56.0
node 26.12.1; darwin 27.2.0 arm64
checkout 0bb6eaa546c91915efbd3f092088c694f3949709
control: created merge with two parents
before: UU note.txt
action: skipped_no_changes; toast: Done
merge still pending: true
Git control: created merge with two parents
RESULT: defect reproduced

Failure source: actual execution of the service and real Git driver. The empty staged summary returns null in apps/server/src/vcs/GitVcsDriverCore.ts#L2707-L2718; the suggestion resolver propagates that null at apps/server/src/git/GitManager.ts#L2160-L2166, and the commit step returns skipped_no_changes at apps/server/src/git/GitManager.ts#L2234-L2244. The supported web entry point submits action: "commit" at apps/web/src/components/GitActionsControl.tsx#L1426-L1437.

Evidence provenance: observed. Local verification: reproduced. Reproduction completeness: complete for the service failure. No runtime UI claim is made. Fixtures use only generated paths and synthetic identity values.

  • Expected source: Git merge contracts and the direct-Git control, cited under Expected behavior.
  • Failure source: the real GitManager action output and pinned source locations above.
  • Evidence provenance: observed.
  • Local verification: reproduced.
  • Reproduction completeness: complete for the service failure.

Restoration check

Restoration check: run the supplied script. Both resolutions must return created, leave two-parent merge commits, and clear MERGE_HEAD; the script must print RESULT: expected behavior and exit 0. The changed-resolution control must continue to pass.

Workaround

Complete the resolved merge in a terminal with git add -A and git commit -m "Complete merge". This was verified in the same failing fixture.

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions