Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Cross-clone worktree isolation**: prevent workflows in one local clone from silently adopting worktrees or DB state owned by another local clone of the same remote. Two clones sharing a remote previously resolved to the same `codebase_id`, causing the isolation resolver's DB-driven paths (`findReusable`, `findLinkedIssueEnv`, `tryBranchAdoption`) to return the other clone's environment. All adoption paths now verify the worktree's `.git` pointer matches the requesting clone and throw a classified error on mismatch. `archon-implement` prompt was also tightened to stop AI agents from adopting unrelated branches they see via `git branch`. Thanks to @halindrome for the three-issue root-cause mapping. (#1193, #1188, #1183, #1198, #1206)

## [0.3.6] - 2026-04-12

Web UI workflow experience improvements, CWD environment leak protection, and bug fixes.
Expand Down
37 changes: 37 additions & 0 deletions packages/docs-web/src/content/docs/reference/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,40 @@ ARCHON_SUPPRESS_NESTED_CLAUDE_WARNING=1 archon workflow run ...
```bash
ARCHON_CLAUDE_FIRST_EVENT_TIMEOUT_MS=120000 archon workflow run ...
```

## Worktree Belongs to a Different Clone

**Symptom:** Running a workflow (especially with `--branch <name>`) from one local clone surfaces one of these errors:

- `Worktree at <path> belongs to a different clone (<other-clone-path>). Remove it from that clone or use a different codebase registration.`
- `Cannot verify worktree ownership at <path>: <reason>`
- `Cannot adopt <path>: path contains a full git checkout, not a worktree.`
- `Cannot adopt <path>: .git pointer is not a git-worktree reference.`

**Cause:** Archon derives codebase identity from the remote URL (`owner/repo`), so two local clones of the same remote share one `codebase_id`. Worktrees are stored under a shared path (`~/.archon/workspaces/<owner>/<repo>/worktrees/`), which means a worktree created by clone A is visible on disk from clone B. The isolation system refuses to silently adopt across clones because it would operate on the wrong filesystem state.

**Fix — pick one:**

1. **Remove the other clone's worktree.** If you no longer need the other clone's in-progress work:

```bash
# From the other clone's directory, find and remove the conflicting worktree
archon isolation list
archon complete <branch-name> # graceful cleanup
# or, if no work to preserve:
git worktree remove <path> --force
```

2. **Use a different branch name** for this run so the two clones don't compete for the same worktree path:

```bash
archon workflow run <name> --branch <different-name> "task"
```

3. **Work from a single clone.** If both local checkouts are for the same project, consolidate to one. Archon's codebase registration currently assumes one local path per remote; true multi-clone support is tracked in [#1192](https://github.com/coleam00/Archon/issues/1192).

**Other variants:**

- `path contains a full git checkout, not a worktree`: something non-Archon created a full git repo at the worktree path. Remove or move it.
- `.git pointer is not a git-worktree reference`: the `.git` file at that path points somewhere unexpected (submodule, malformed). Inspect it with `cat <path>/.git` and clean up manually.
- `Cannot verify worktree ownership`: filesystem permission or I/O error reading `<path>/.git`. Check `ls -la <path>` and file permissions on `~/.archon/workspaces`.
115 changes: 115 additions & 0 deletions packages/git/src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1894,4 +1894,119 @@ branch refs/heads/feature/auth
);
});
});

describe('verifyWorktreeOwnership', () => {
test('resolves for matching worktree pointer', async () => {
await writeFile(
join(testDir, '.git'),
'gitdir: /workspace/my-repo/.git/worktrees/issue-42\n'
);

await expect(
git.verifyWorktreeOwnership(
git.toWorktreePath(testDir),
git.toRepoPath('/workspace/my-repo')
)
).resolves.toBeUndefined();
});

test('throws with "belongs to a different clone" when gitdir points elsewhere', async () => {
await writeFile(join(testDir, '.git'), 'gitdir: /other/clone/.git/worktrees/issue-42\n');

await expect(
git.verifyWorktreeOwnership(
git.toWorktreePath(testDir),
git.toRepoPath('/workspace/my-repo')
)
).rejects.toThrow(/belongs to a different clone \(\/other\/clone\)/);
});

test('normalizes trailing slashes in both paths', async () => {
await writeFile(
join(testDir, '.git'),
'gitdir: /workspace/my-repo/.git/worktrees/issue-42\n'
);

await expect(
git.verifyWorktreeOwnership(
git.toWorktreePath(testDir),
git.toRepoPath('/workspace/my-repo/')
)
).resolves.toBeUndefined();
});

test('throws EISDIR when .git is a directory (full checkout at path)', async () => {
await realMkdir(join(testDir, '.git'));

const promise = git.verifyWorktreeOwnership(
git.toWorktreePath(testDir),
git.toRepoPath('/workspace/my-repo')
);
await expect(promise).rejects.toThrow(/path contains a full git checkout/);
// Original errno is preserved on the wrapped error for robust
// classification downstream (not just a fragile substring match).
try {
await git.verifyWorktreeOwnership(
git.toWorktreePath(testDir),
git.toRepoPath('/workspace/my-repo')
);
} catch (err) {
expect((err as NodeJS.ErrnoException).code).toBe('EISDIR');
}
});

test('throws ENOENT when .git file is missing', async () => {
await expect(
git.verifyWorktreeOwnership(
git.toWorktreePath(testDir),
git.toRepoPath('/workspace/my-repo')
)
).rejects.toThrow(/Cannot verify worktree ownership/);
try {
await git.verifyWorktreeOwnership(
git.toWorktreePath(testDir),
git.toRepoPath('/workspace/my-repo')
);
} catch (err) {
expect((err as NodeJS.ErrnoException).code).toBe('ENOENT');
}
});

test('throws on submodule pointer (gitdir into .git/modules/...)', async () => {
await writeFile(
join(testDir, '.git'),
'gitdir: /workspace/my-repo/.git/modules/vendor/submodule\n'
);

await expect(
git.verifyWorktreeOwnership(
git.toWorktreePath(testDir),
git.toRepoPath('/workspace/my-repo')
)
).rejects.toThrow(/not a git-worktree reference/);
});

test('throws on corrupted .git content (no gitdir prefix)', async () => {
await writeFile(join(testDir, '.git'), 'this is not a git pointer at all');

await expect(
git.verifyWorktreeOwnership(
git.toWorktreePath(testDir),
git.toRepoPath('/workspace/my-repo')
)
).rejects.toThrow(/not a git-worktree reference/);
});

test('preserves original error via `cause` chain on fs errors', async () => {
try {
await git.verifyWorktreeOwnership(
git.toWorktreePath(testDir),
git.toRepoPath('/workspace/my-repo')
);
} catch (err) {
expect((err as Error).cause).toBeDefined();
expect(((err as Error).cause as NodeJS.ErrnoException).code).toBe('ENOENT');
}
});
});
});
1 change: 1 addition & 0 deletions packages/git/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {
isWorktreePath,
removeWorktree,
getCanonicalRepoPath,
verifyWorktreeOwnership,
} from './worktree';

// Branch operations
Expand Down
78 changes: 77 additions & 1 deletion packages/git/src/worktree.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFile, access } from 'fs/promises';
import { join } from 'path';
import { join, resolve } from 'path';
import {
createLogger,
getArchonWorktreesPath,
Expand Down Expand Up @@ -256,6 +256,82 @@ export async function getCanonicalRepoPath(path: string): Promise<RepoPath> {
return toRepoPath(path);
}

/**
* Verify that the worktree at the given path belongs to the expected repo.
*
* Throws if the worktree's parent repo doesn't match the request, or if
* ownership cannot be determined. The caller relies on the throw-or-return
* contract: a successful return means the caller may safely adopt the
* worktree. This is intentionally strict — a permissive fallback here
* would re-introduce the cross-checkout bug this guard exists to prevent.
*
* Paths are normalized with `resolve()` before comparison to handle trailing
* slashes and relative components. Symlinked paths (where canonical vs
* registered paths differ by symlink resolution) are not equated — callers
* should register codebases with consistent path forms.
*
* Error classification (surfaced via `classifyIsolationError` in
* `@archon/isolation/errors.ts`):
* - "path contains a full git checkout" → EISDIR
* - "Cannot verify worktree ownership" → ENOENT / EACCES / EIO
* - "not a git-worktree reference" → submodule pointer or malformed
* - "belongs to a different clone" → cross-checkout
*/
export async function verifyWorktreeOwnership(
worktreePath: WorktreePath,
expectedRepo: RepoPath
): Promise<void> {
let gitContent: string;
try {
gitContent = await readFile(join(worktreePath, '.git'), 'utf-8');
} catch (error) {
const err = error as NodeJS.ErrnoException;
// Preserve the original errno on the wrapped error so downstream
// classifiers can match by `.code` instead of substring — resilient to
// Node.js message format changes. The original error is also kept via
// `cause` for debugging.
const wrap = (message: string): Error => {
const wrapped = new Error(message, { cause: err });
if (err.code) (wrapped as NodeJS.ErrnoException).code = err.code;
return wrapped;
};
// EISDIR: .git is a directory — path holds a full checkout, not a
// worktree. Refusing adoption prevents accidentally treating an
// unrelated repo at this path as ours.
if (err.code === 'EISDIR') {
throw wrap(
`Cannot adopt ${worktreePath}: path contains a full git checkout, not a worktree.`
);
}
// ENOENT: .git file missing despite worktreeExists() reporting true —
// a TOCTOU race or filesystem corruption. Fail fast.
// EACCES/EIO/etc.: cannot verify ownership — fail fast rather than
// defaulting to permissive adoption.
throw wrap(`Cannot verify worktree ownership at ${worktreePath}: ${err.message}`);
}

// gitdir: /path/to/repo/.git/worktrees/branch-name
const match = /gitdir: (.+)\/\.git\/worktrees\//.exec(gitContent);
if (!match) {
// Not a git-worktree pointer (e.g., submodule pointer, or malformed).
// We cannot confirm this is our worktree, so refuse adoption.
throw new Error(`Cannot adopt ${worktreePath}: .git pointer is not a git-worktree reference.`);
}

// Compare on resolved paths (normalizes trailing slashes and relative
// components) but display the raw path from the .git pointer so the user
// sees the value they'd recognize. On Windows, `resolve()` would prepend
// a drive letter to the POSIX-style gitdir, making the error message
// misleading and causing platform-specific test breakage.
const existingRepoRaw = match[1];
if (resolve(existingRepoRaw) !== resolve(expectedRepo)) {
throw new Error(
`Worktree at ${worktreePath} belongs to a different clone (${existingRepoRaw}). ` +
'Remove it from that clone or use a different codebase registration.'
);
}
}

/**
* Extract owner and repo name from the last two segments of a repository path.
* Throws if the path has fewer than 2 non-empty segments.
Expand Down
21 changes: 21 additions & 0 deletions packages/isolation/src/providers/worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,8 @@ describe('WorktreeProvider', () => {
worktreeExistsSpy.mockResolvedValueOnce(false);
// findWorktreeByBranch finds existing worktree
findWorktreeByBranchSpy.mockResolvedValue('/workspace/worktrees/repo/feature-auth');
// Same-clone ownership match so adoption proceeds
mockReadFile.mockResolvedValue('gitdir: /workspace/repo/.git/worktrees/feature-auth\n');

const env = await provider.create(request);

Expand All @@ -605,6 +607,25 @@ describe('WorktreeProvider', () => {
expect(addCalls).toHaveLength(0);
});

test('throws when PR-branch-adopted worktree belongs to a different clone', async () => {
const request: PRIsolationRequest = {
codebaseId: 'cb-123',
canonicalRepoPath: '/workspace/repo',
workflowType: 'pr',
identifier: '42',
prBranch: 'feature/auth',
isForkPR: false,
};

// Primary path misses, secondary findWorktreeByBranch hits
worktreeExistsSpy.mockResolvedValueOnce(false);
findWorktreeByBranchSpy.mockResolvedValue('/workspace/worktrees/repo/feature-auth');
// .git points to a different clone
mockReadFile.mockResolvedValue('gitdir: /other/clone/.git/worktrees/feature-auth\n');

await expect(provider.create(request)).rejects.toThrow(/belongs to a different clone/);
});

test('resets stale branch to start-point when it already exists', async () => {
let callCount = 0;
execSpy.mockImplementation(async (_cmd: string, args: string[]) => {
Expand Down
Loading
Loading