Skip to content

Commit 0f83aba

Browse files
authored
fix(git): preserve clone URLs without appending .git (#1188)
* fix(git): preserve clone URLs without appending .git * test(e2e): map suffixless URLs to the local Git fixture
1 parent a3ac3b4 commit 0f83aba

7 files changed

Lines changed: 186 additions & 6 deletions

File tree

‎docs/guide/providers.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ teamai init https://code.qschou.com/Enterprise/arb-workflow-kit.git --scope user
5555
teamai init git@code.qschou.com:Enterprise/arb-workflow-kit.git --scope user
5656
```
5757

58+
The generic provider preserves the clone path, including whether it ends in `.git`. Azure Repos uses this provider too:
59+
60+
```bash
61+
teamai init https://dev.azure.com/org/project/_git/repo
62+
```
63+
5864
The generic provider neither reads nor stores a platform token; the system `git` handles authentication:
5965

6066
- HTTPS: configure a Git credential helper first. Do not put a username, password or token in the URL.

‎docs/guide/zh-CN/providers.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ teamai init https://code.qschou.com/Enterprise/arb-workflow-kit.git --scope user
5555
teamai init git@code.qschou.com:Enterprise/arb-workflow-kit.git --scope user
5656
```
5757

58+
通用 provider 保留克隆路径,包括原地址是否带 `.git` 后缀。Azure Repos 也使用此 provider:
59+
60+
```bash
61+
teamai init https://dev.azure.com/org/project/_git/repo
62+
```
63+
5864
通用 provider 不读取或保存平台 Token,而是让系统 `git` 处理认证:
5965

6066
- HTTPS:预先配置 Git Credential Helper;不要把用户名、密码或 Token 写进 URL。

‎skill-data/setup/references/join-member.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ Match the login to the URL's host (do NOT create a second repo):
6767
the token: add `--provider git` to the `init` in Step 4. Git then uses their
6868
existing SSH key or credential helper, and `push` leaves the MR for them to open
6969
on the web.
70+
- **Other Git hosts, including Azure Repos** → use the existing Git credential
71+
helper or SSH key. Keep the clone path as provided, including whether it ends
72+
in `.git`; do not embed HTTPS credentials in the URL.
7073

7174
If they have no account on that platform, they register there, then ask the admin
7275
to add them to the repo.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2+
import { execFileSync } from 'node:child_process';
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
import YAML from 'yaml';
8+
9+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
10+
const CLI = path.join(ROOT, 'dist/index.js');
11+
const REPO_URL = 'https://dev.azure.com/teamai-repro/My%20Project/_git/teamai-oe';
12+
13+
describe('generic Git clone paths without a .git suffix (#1169)', () => {
14+
let sandbox: string;
15+
let home: string;
16+
let workspace: string;
17+
let seed: string;
18+
let remote: string;
19+
let env: NodeJS.ProcessEnv;
20+
21+
function git(args: string[], cwd = sandbox): string {
22+
return execFileSync('git', args, { cwd, env, encoding: 'utf8', timeout: 30_000 }).trim();
23+
}
24+
25+
function cli(args: string[]): string {
26+
return execFileSync(process.execPath, [CLI, ...args], {
27+
cwd: workspace, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
28+
timeout: 30_000, windowsHide: true,
29+
});
30+
}
31+
32+
beforeEach(() => {
33+
sandbox = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-git-suffixless-')));
34+
home = path.join(sandbox, 'home');
35+
workspace = path.join(sandbox, 'workspace');
36+
seed = path.join(sandbox, 'seed');
37+
remote = path.join(sandbox, 'remotes', 'teamai-oe');
38+
for (const dir of [home, workspace, path.dirname(remote), path.join(seed, 'manifest')]) {
39+
fs.mkdirSync(dir, { recursive: true });
40+
}
41+
env = {
42+
...process.env,
43+
HOME: home,
44+
USERPROFILE: home,
45+
GIT_CONFIG_GLOBAL: path.join(home, '.gitconfig'),
46+
GIT_CONFIG_NOSYSTEM: '1',
47+
GIT_AUTHOR_NAME: 'TeamAI CI',
48+
GIT_AUTHOR_EMAIL: 'ci@teamai.test',
49+
GIT_COMMITTER_NAME: 'TeamAI CI',
50+
GIT_COMMITTER_EMAIL: 'ci@teamai.test',
51+
GIT_TERMINAL_PROMPT: '0',
52+
TEAMAI_NONINTERACTIVE: '1',
53+
FORCE_COLOR: '0',
54+
};
55+
fs.writeFileSync(path.join(seed, 'teamai.yaml'), YAML.stringify({
56+
team: 'suffixless-e2e', repo: REPO_URL, provider: 'git', reviewers: [],
57+
}));
58+
fs.writeFileSync(path.join(seed, 'manifest', 'roles.yaml'), YAML.stringify({
59+
version: 1,
60+
roles: [{ id: 'dev', resources: { knowledge: [], skills: ['common'] } }],
61+
}));
62+
const skill = path.join(seed, 'skills', 'common', 'suffixless-demo');
63+
fs.mkdirSync(skill, { recursive: true });
64+
fs.writeFileSync(path.join(skill, 'SKILL.md'),
65+
'---\nname: suffixless-demo\ndescription: Suffixless Git fixture\n---\n\n# Original\n');
66+
git(['init', '-q', '-b', 'main'], seed);
67+
git(['add', '-A'], seed);
68+
git(['commit', '-q', '-m', 'seed'], seed);
69+
git(['clone', '-q', '--bare', seed, remote]);
70+
// Rewrite only the prefix: an added .git must point at a missing repository.
71+
git(['config', '--global', `url.${path.dirname(remote).replace(/\\/g, '/')}/.insteadOf`,
72+
'https://dev.azure.com/teamai-repro/My%20Project/_git/']);
73+
});
74+
75+
afterEach(() => {
76+
if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true });
77+
});
78+
79+
it('initializes, preserves the saved remote, and pulls a later skill update', () => {
80+
// Establish that the original URL is cloneable before exercising TeamAI.
81+
git(['clone', '-q', REPO_URL, path.join(sandbox, 'direct-clone')]);
82+
cli(['init', REPO_URL, '--provider', 'git', '--scope', 'workspace',
83+
'--role', 'dev', '--agent', 'claude', '--force']);
84+
const config = YAML.parse(fs.readFileSync(path.join(workspace, '.teamai', 'config.yaml'), 'utf8')) as {
85+
repo: { remote: string; localPath: string };
86+
};
87+
expect(config.repo.remote).toBe(REPO_URL);
88+
expect(git(['config', '--get', 'remote.origin.url'], config.repo.localPath)).toBe(REPO_URL);
89+
cli(['pull']);
90+
const delivered = path.join(workspace, '.claude', 'skills', 'suffixless-demo', 'SKILL.md');
91+
expect(fs.readFileSync(delivered, 'utf8')).toContain('# Original');
92+
93+
const skill = path.join(seed, 'skills', 'common', 'suffixless-demo', 'SKILL.md');
94+
fs.appendFileSync(skill, '\nUpdated through the suffixless remote.\n');
95+
git(['add', '-A'], seed);
96+
git(['commit', '-q', '-m', 'update skill'], seed);
97+
git(['push', '-q', remote, 'HEAD:main'], seed);
98+
cli(['pull']);
99+
expect(fs.readFileSync(delivered, 'utf8')).toContain('Updated through the suffixless remote.');
100+
});
101+
});

‎src/__tests__/e2e/self-mode-worktrees-808.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1494,6 +1494,9 @@ describe('a checkout an older git-mode install left (#808)', () => {
14941494
const { home, projectRoot } = install;
14951495
const teamA = serveTeamRepo(install, 'team-a');
14961496
const teamB = serveTeamRepo(install, 'team-b');
1497+
// Both URL forms below must resolve to the same local fixture.
1498+
git(['config', '--file', path.join(home, '.gitconfig'), '--add',
1499+
`url.${teamA.bare}.insteadOf`, teamA.url.replace(/\.git$/, '')], projectRoot);
14971500

14981501
const initA = await runCLI(['init', teamA.url, '--scope', 'project', '--force'], projectRoot, home);
14991502
expect(initA.code, initA.output).toBe(0);

‎src/__tests__/generic-git-provider.test.ts‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ describe('generic Git provider detection', () => {
1717
expect(detectProvider('HTTPS://code.qschou.com/Enterprise/arb-workflow-kit.git')).toBe('git');
1818
expect(detectProvider('git@code.qschou.com:Enterprise/arb-workflow-kit.git')).toBe('git');
1919
expect(detectProvider('ssh://git@code.qschou.com/Enterprise/arb-workflow-kit.git')).toBe('git');
20+
expect(detectProvider('https://dev.azure.com/org/project/_git/repo')).toBe('git');
21+
expect(detectProvider('git@ssh.dev.azure.com:v3/org/project/repo')).toBe('git');
2022
});
2123

2224
it('keeps known hosts on their dedicated providers', () => {
@@ -54,17 +56,49 @@ describe('parseGenericGitRepoInput', () => {
5456
'ssh://git@code.qschou.com:2222/Enterprise/arb-workflow-kit',
5557
);
5658
expect(ssh.httpsUrl).toBe(
57-
'ssh://git@code.qschou.com:2222/Enterprise/arb-workflow-kit.git',
59+
'ssh://git@code.qschou.com:2222/Enterprise/arb-workflow-kit',
5860
);
5961
});
6062

63+
it.each([
64+
'https://dev.azure.com/org/project/_git/repo',
65+
'https://dev.azure.com/org/My%20Project/_git/repo',
66+
'https://org.visualstudio.com/project/_git/repo',
67+
'git@ssh.dev.azure.com:v3/org/project/repo',
68+
'ssh://git@ssh.dev.azure.com:2222/v3/org/project/repo',
69+
'https://git.example.com/group/repo',
70+
'https://git.example.com/group/repo.git',
71+
'https://git.example.com/group/repo.GIT',
72+
])('preserves the clone path across repeated parsing: %s', (input) => {
73+
const parsed = parseGenericGitRepoInput(input);
74+
expect(parsed.httpsUrl).toBe(input);
75+
expect(parsed.repo).toBe('repo');
76+
expect(parseGenericGitRepoInput(parsed.httpsUrl)).toEqual(parsed);
77+
});
78+
79+
it('keeps the same repository identity with or without a clone suffix', () => {
80+
const input = 'https://dev.azure.com/org/My%20Project/_git/repo';
81+
const identity = {
82+
owner: 'org/My%20Project/_git',
83+
repo: 'repo',
84+
projectId: encodeURIComponent('org/My%20Project/_git/repo'),
85+
};
86+
expect(parseGenericGitRepoInput(input)).toMatchObject(identity);
87+
expect(parseGenericGitRepoInput(`${input}.git/`)).toMatchObject({
88+
...identity, httpsUrl: `${input}.git`,
89+
});
90+
});
91+
6192
it('rejects bare names and embedded HTTPS credentials', () => {
6293
expect(() => parseGenericGitRepoInput('Enterprise/arb-workflow-kit')).toThrow(
6394
/Invalid Git repo URL/,
6495
);
6596
expect(() => parseGenericGitRepoInput(
6697
'https://oauth2:secret@code.qschou.com/Enterprise/arb-workflow-kit.git',
6798
)).toThrow(/Do not embed credentials/);
99+
expect(() => parseGenericGitRepoInput(
100+
'https://org@dev.azure.com/org/project/_git/repo',
101+
)).toThrow(/Do not embed credentials/);
68102
});
69103

70104
it('rejects scp-style embedded credentials but keeps a real SSH login', () => {
@@ -106,6 +140,17 @@ describe('parseGenericGitRepoInput', () => {
106140
projectId: encodeURIComponent('group/repo'),
107141
});
108142
});
143+
144+
it('preserves a suffixless existing origin while stripping credentials', () => {
145+
expect(parseGenericGitExistingRemote(
146+
'http://user:secret@git.example.com/group/repo',
147+
)).toEqual({
148+
owner: 'group',
149+
repo: 'repo',
150+
httpsUrl: 'http://git.example.com/group/repo',
151+
projectId: encodeURIComponent('group/repo'),
152+
});
153+
});
109154
});
110155

111156
describe('generic Git identity', () => {
@@ -138,6 +183,20 @@ describe('GenericGitProvider transport', () => {
138183
);
139184
});
140185

186+
it.each([
187+
'https://dev.azure.com/org/project/_git/repo',
188+
'git@ssh.dev.azure.com:v3/org/project/repo',
189+
'ssh://git@ssh.dev.azure.com:2222/v3/org/project/repo',
190+
])('passes the suffixless clone URL to Git unchanged: %s', (input) => {
191+
mockedSpawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' });
192+
new GenericGitProvider().cloneRepo(input, '/tmp/team-repo');
193+
expect(mockedSpawnSync).toHaveBeenCalledWith(
194+
'git',
195+
['clone', '--', input, '/tmp/team-repo'],
196+
expect.objectContaining({ timeout: 180_000, windowsHide: true }),
197+
);
198+
});
199+
141200
it('checks that git is installed before transport operations', async () => {
142201
mockedSpawnSync.mockReturnValue({ status: 0, stdout: 'git version 2.50.0\n', stderr: '' });
143202

‎src/providers/git/repo-url.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ function invalidRepoUrl(reason?: string): Error {
99
}
1010

1111
function parsePath(rawPath: string): { owner: string; repo: string; fullPath: string } {
12-
const cleanPath = rawPath
12+
const fullPath = rawPath
1313
.replace(/^\/+/, '')
14-
.replace(/\/+$/, '')
14+
.replace(/\/+$/, '');
15+
const cleanPath = fullPath
1516
.replace(/\.git$/i, '')
1617
.replace(/\/+$/, '');
1718
const segments = cleanPath.split('/').filter(Boolean);
@@ -25,7 +26,7 @@ function parsePath(rawPath: string): { owner: string; repo: string; fullPath: st
2526

2627
const repo = segments[segments.length - 1];
2728
const owner = segments.slice(0, -1).join('/');
28-
return { owner, repo, fullPath: `${owner}/${repo}` };
29+
return { owner, repo, fullPath };
2930
}
3031

3132
function buildRepoInfo(owner: string, repo: string, remoteUrl: string): RepoInfo {
@@ -74,7 +75,8 @@ function parseGenericGitRepoInputWithOptions(input: string, options: ParseOption
7475
// A pre-existing local origin may contain HTTP Basic credentials. They are
7576
// needed only by the user's Git config, never by TeamAI configuration.
7677
const auth = /^ssh:$/i.test(parsed.protocol) && parsed.username ? `${parsed.username}@` : '';
77-
const remoteUrl = `${parsed.protocol}//${auth}${parsed.host}/${fullPath}.git`;
78+
// Git hosts such as Azure Repos require the clone path without an added .git.
79+
const remoteUrl = `${parsed.protocol}//${auth}${parsed.host}/${fullPath}`;
7880
return buildRepoInfo(owner, repo, remoteUrl);
7981
}
8082

@@ -90,7 +92,7 @@ function parseGenericGitRepoInputWithOptions(input: string, options: ParseOption
9092
);
9193
}
9294
const { owner, repo, fullPath } = parsePath(rawPath);
93-
return buildRepoInfo(owner, repo, `${user}@${host}:${fullPath}.git`);
95+
return buildRepoInfo(owner, repo, `${user}@${host}:${fullPath}`);
9496
}
9597

9698
throw invalidRepoUrl();

0 commit comments

Comments
 (0)