Skip to content

[Bug]: Projects inside a repository whose root ends in a space lose repository identity #35747

Closed

Description

@coygeek

Summary

Adding a project below a repository root whose name ends in a space loses its repository identity even though Git recognizes the checkout and remote. The resolver removes the space from Git’s root path, then queries a different directory. A run of the real resolver reproduced the missing identity, while an otherwise identical checkout with a plain directory name worked.

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. Initialize two disposable repositories, one named plain and another named spaced (with a trailing ASCII space). Give each the same origin remote and an app subdirectory.
  2. Resolve the identity of each app directory using the real server service.
  3. Check that Git returns the exact root, including the trailing space, but T3 returns a null identity only for the spaced root. The script below performs these steps.

Both the exploratory run and the isolated script run reproduced the failure. The application-level trigger is Add project → Local folder → select the nested app directory; the source path from project enrichment to the resolver was inspected, but no client was launched.

Save the following as repro.sh and run sh repro.sh /path/to/t3code. Prerequisites: a clean checkout of the stated revision with its dependencies installed, Node.js 26, and Git on PATH. It creates repositories and all state under a temporary directory, makes no network connections, and removes its fixtures on exit. Exit 1 means the defect was reproduced, 0 means the expected behavior holds, and 2 means the control did not establish a valid setup.

Reproduction script: repro.sh
#!/bin/sh
set -eu
scratch=$(mktemp -d)
finished=0
cleanup() {
  status=$?
  rm -rf "$scratch"
  if [ "$finished" -eq 0 ]; then
    echo 'RESULT: inconclusive'
    exit 2
  fi
  exit "$status"
}
trap cleanup EXIT
trap 'exit 2' HUP INT TERM
checkout=$(cd "$1" && pwd -P)
mkdir -p "$scratch/home" "$scratch/tmp" "$scratch/state"
export HOME="$scratch/home" TMPDIR="$scratch/tmp" T3CODE_HOME="$scratch/state"
export GIT_CONFIG_NOSYSTEM=1
unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_CEILING_DIRECTORIES
node --version
git --version
git -C "$checkout" rev-parse HEAD
cat > "$scratch/repro.mjs" <<'JS'
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
try {
const [checkout, scratch] = process.argv.slice(2);
const require = createRequire(join(checkout, 'apps/server/package.json'));
const load = (name) => import(pathToFileURL(require.resolve(name)).href);
const Effect = await load('effect/Effect');
const NodeServices = await load('@effect/platform-node/NodeServices');
const Resolver = await import(pathToFileURL(join(checkout, 'apps/server/src/project/RepositoryIdentityResolver.ts')).href);
const git = (cwd, ...args) => execFileSync('git', ['-C', cwd, ...args], { stdio: ['ignore', 'pipe', 'pipe'] }).toString();
const plain = join(scratch, 'plain'), spaced = join(scratch, 'spaced ');
for (const root of [plain, spaced]) {
  mkdirSync(join(root, 'app'), { recursive: true });
  git(root, 'init');
  git(root, 'remote', 'add', 'origin', 'https://github.com/example/project.git');
}
const read = (cwd) => Effect.runPromise(Effect.gen(function* () {
  const resolver = yield* Resolver.RepositoryIdentityResolver;
  return yield* resolver.resolve(cwd);
}).pipe(Effect.provide(Resolver.layer), Effect.provide(NodeServices.layer)));
const control = (await read(join(plain, 'app')))?.canonicalKey;
console.log('control:', control);
if (control !== 'github.com/example/project') { console.log('RESULT: inconclusive'); process.exit(2); }
const root = git(join(spaced, 'app'), 'rev-parse', '--show-toplevel');
console.log('Git preserves trailing space:', root.endsWith('spaced \n'));
if (!root.endsWith('spaced \n')) { console.log('RESULT: inconclusive'); process.exit(2); }
const actual = await read(join(spaced, 'app'));
console.log('spaced repository identity:', actual === null ? null : actual.canonicalKey);
if (actual?.canonicalKey === control) { console.log('RESULT: expected behavior'); process.exit(0); }
if (actual === null) { console.log('RESULT: defect reproduced'); process.exit(1); }
console.log('RESULT: inconclusive'); process.exit(2);
} catch { console.log('RESULT: inconclusive'); process.exit(2); }
JS
set +e
node "$scratch/repro.mjs" "$checkout" "$scratch"
status=$?
finished=1
exit "$status"

Expected behavior

Both nested projects resolve to github.com/example/project, using their actual repository roots. Git’s --show-toplevel contract returns the working-tree root path. T3’s existing nested-workspace test at apps/server/src/project/RepositoryIdentityResolver.test.ts#L114-L140 expects the same identity from a subdirectory as from its repository root. Whitespace is part of a valid POSIX directory name; the direct Git control proves that the fixture is a recognized checkout.

Expected source: the Git contract and existing resolver tests linked above.

Actual behavior

For a project directory spaced /app inside a repository rooted at spaced , git rev-parse --show-toplevel preserves the trailing space and exits successfully. The real RepositoryIdentityResolver.resolve nevertheless returns null, although that repository has origin=https://github.com/example/project.git. The same resolver returns github.com/example/project for the control plain/app.

Impact

Minor bug or occasional failure

Impact: P2 - A valid local project loses repository metadata and the identity required by remote-project actions. A checkout in a whitespace-free directory resolves correctly in the control.

Observed on macOS with a nested project under a root ending in one ASCII space. A whitespace-free repository control works. The source shows that a null identity makes useAddProjectTargets return no destinations, so Add to another machine is unavailable for this project. That UI consequence was traced in source, not exercised in a running client. Moving the disposable repository to a whitespace-free root is represented by the otherwise identical control; moving a real checkout may require updating external path references.

Version or commit

main @ 0bb6eaa546c91915efbd3f092088c694f3949709. The failure was reproduced against this source checkout with its installed lockfile dependencies; the packaged desktop release was not exercised.

Environment

macOS (darwin), arm64; Node.js v26.12.1; Git 2.56.0. The reproduction imports the real server service and uses real local Git processes without contacting any remote.

Logs or stack traces

Evidence

Observed output from the isolated script (exit 1):

v26.12.1
git version 2.56.0
0bb6eaa546c91915efbd3f092088c694f3949709
control: github.com/example/project
Git preserves trailing space: true
spaced repository identity: null
RESULT: defect reproduced

Failure source: observed execution of the actual resolver. At apps/server/src/project/RepositoryIdentityResolver.ts#L170-L186, topLevelResult.value.stdout.trim() removes the directory name’s final space, and apps/server/src/project/RepositoryIdentityResolver.ts#L245-L254 subsequently runs Git with that shortened -C path and returns null after the command fails.

Supported entry point: apps/server/src/project/ProjectEnrichmentService.ts#L114-L124 resolves project roots; apps/web/src/hooks/useAddProjectTargets.ts#L16-L33 suppresses clone destinations when identity is missing. The nested path is preserved by workspace normalization because its last component is app, not the component ending in a space.

Evidence provenance: observed. Local verification: reproduced. Reproduction completeness: complete for the resolver failure. Missing fact: NOT_ENOUGH_INFO the downstream client presentation was not exercised; a client run against the same fixture would verify it. All paths are generated at runtime; the output contains no private machine paths.

Workaround

Use a checkout in a directory whose name does not end in a space (the plain-root control resolves correctly). Moving an existing checkout may require updating external path references; moving a live checkout was not tested.

Restoration check

run the script against the candidate change. The exact physical root must retain its trailing space, both lookups must return github.com/example/project, and the script must print RESULT: expected behavior and exit 0.

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