Repository navigation
Description
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
- Create
mainandtopicbranches that change the same file differently, then rungit merge topiconmainto produce a content conflict. - 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.
- Invoke the Commit action with a custom message. It returns
skipped_no_changesandDone, leaving the merge pending. - In the same failing fixture,
git add -Afollowed bygit 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", "test@example.invalid");
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
- [Bug]: Commit goes through with conflict markers still in the file #35095 — concerns committing unresolved markers; this case has no remaining markers and instead silently skips a valid resolved merge.
- fix(server): Commit refuses while a merge still has conflict markers #35132 — added the marker guard; the current reproduction passes that guard and fails at the later empty-summary check.
Activity