Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
57d2375
✨ feat(node-utils): tell whether a git remote and a provider URL are …
quentinlebourles-packmind Sep 29, 2026
dc8bd6d
✨ feat(git): resolve a CLI remote against the providers of its host
quentinlebourles-packmind Sep 29, 2026
05b2968
✨ feat(git): adopt a self-hosted CLI-managed repository into its toke…
quentinlebourles-packmind Sep 29, 2026
8f57dc1
♻️ refactor(cli): let the server work out the git provider from the r…
quentinlebourles-packmind Sep 29, 2026
7c1f29e
📝 docs: explain how a connection takes over repositories the CLI reco…
quentinlebourles-packmind Sep 29, 2026
a727fd5
🐛 fix(git): keep the whole group path of a GitLab subgroup remote
quentinlebourles-packmind Sep 29, 2026
8de4e42
🚨 fix(integration-tests): stop asserting on the logger in the self-ho…
quentinlebourles-packmind Sep 29, 2026
ff02863
🐛 fix(node-utils): read a bracketed IPv6 host as a whole
quentinlebourles-packmind Sep 29, 2026
da936af
🐛 fix(git): delete an emptied CLI-managed provider in one statement
quentinlebourles-packmind Sep 29, 2026
36f7be2
🐛 fix(git): accept an scp-like remote naming an absolute path
quentinlebourles-packmind Sep 29, 2026
9f7a724
🐛 fix(git): match a remote of an instance installed under a path prefix
quentinlebourles-packmind Sep 29, 2026
b666124
♻️ refactor(git): bind the provider id in the live-repository subquery
quentinlebourles-packmind Sep 29, 2026
a1cb362
🐛 fix(git): read a path prefix as its own connection's only
quentinlebourles-packmind Sep 29, 2026
c09b37d
🐛 fix(git): harden self-hosted resolution against legacy and prefixed…
quentinlebourles-packmind Sep 29, 2026
78e37a2
🐛 fix(node-utils): read a remote sent without a scheme again
quentinlebourles-packmind Sep 30, 2026
b54d30d
🐛 fix(git): read a tracked owner on the remote's host only
quentinlebourles-packmind Sep 30, 2026
a1a4322
✅ test(cli-e2e): gate install-at-a-version above the published 0.36.1
quentinlebourles-packmind Sep 30, 2026
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
7 changes: 7 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@

- A clone URL embedding a token (`https://oauth2:<token>@host`) no longer leaks it into server logs or the Git connections page
- Remotes stored with such a token are cleaned by a migration
- On a self-managed GitLab, `packmind install`, `init` and `track` now use the connection configured for the instance instead of a CLI-managed one
- That connection is matched by host, so a remote cloned over HTTPS or SSH reaches it alike
- Adding a repository the CLI already recorded on that instance moves it under the connection with its targets and history, instead of refusing it as a duplicate
- A CLI-managed connection is no longer listed once it holds no repository
- A repository cloned over `ssh://` or with a user in its URL no longer gets a CLI-managed connection of its own
- A self-managed GitLab installed under a path such as `/gitlab` matches its connection: the path is no longer read as part of the group
- Opening a component from All components keeps the rows already picked, and closing it goes back to that list rather than to the package carrying the component
- A GitLab repository in a subgroup keeps its full path (`group/subgroup/repo`) when tracked from the CLI, so a token or app connection links it instead of duplicating it

## Removed

Expand Down
9 changes: 5 additions & 4 deletions apps/cli-e2e-tests/src/install-package-versions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ import { Package, Skill } from '@packmind/types';
* tracking the package" and its absence says "this repo is on a release".
*/
/*
* Strictly greater, not `>= 0.36.0`: the registry leg runs the latest
* published CLI, and 0.36.0 is published without any of this. A `>=` gate let
* every scenario below run against a binary that answers `*` to all of them.
* Strictly greater, not `>= 0.36.1`: the registry leg runs the latest
* published CLI, and 0.36.0 and 0.36.1 are published without any of this. A
* looser gate lets every scenario below run against a binary that answers `*`
* to all of them.
*/
describeForVersion('> 0.36.0', 'install at a version', () => {
describeForVersion('> 0.36.1', 'install at a version', () => {
describeWithUserSignedUp('install at a version', (getContext) => {
let context: UserSignedUpContext;
let pkg: Package;
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

## Changed

- `packmind track` and `packmind init` no longer send a provider vendor guessed from the remote URL: the server works it out from the remote, which is what lets a self-managed GitLab use its connection

## Fixed

- The CLI drops any token embedded in the repository's remote URL before sending it to Packmind
Expand All @@ -16,6 +18,8 @@

# [0.36.1] - 2026-09-28

- `packmind track`, `init` and `install` keep the full group path of a GitLab remote (`group/subgroup/repo`) instead of its last two segments

## Added

- Errors are now recorded with their stack in `~/.packmind/error.log`, and network failures with the request and cause behind them
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ describe('TrackRepositoryUseCase', () => {
repo: 'my-repo',
branch: 'dev',
origin: 'track',
providerVendor: 'github',
gitRemoteUrl: REMOTE_URL,
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { GitProviderVendor } from '@packmind/types';
import {
ITrackRepositoryUseCase,
TrackRepositoryCommand,
Expand All @@ -7,40 +6,39 @@
import { IRepositoryTrackingGateway } from '../../../domain/repositories/IRepositoryTrackingGateway';
import { IGitService } from '../../../domain/services/IGitService';

// `scheme://[user@]host[:port]/path`: https, http, ssh, git+ssh alike.
const SCHEME_URL_PATH = /^[a-z][a-z0-9+.-]*:\/\/[^/]+\/(.+)$/i;
// scp-like SSH, `[user@]host:path`, which git writes without a scheme.
const SCP_LIKE_PATH = /^(?:[^@/\s]+@)?[^/:\s]+:(?!\/\/)(.+)$/;

/**
* Parse a git remote URL to extract owner and repo.
* Parse a git remote URL to extract owner and repo. The repo is the last path
* segment and the owner everything before it, so a GitLab subgroup stays whole.
* Mirrors the backend `parseGitRepoInfo` helper so both sides agree.
*/
export function parseOwnerRepo(gitRemoteUrl: string): {
owner: string;
repo: string;
} {
const match = gitRemoteUrl.match(/[/:]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/i);
const path = (gitRemoteUrl.trim().match(SCHEME_URL_PATH) ??

Check warning on line 23 in apps/cli/src/application/useCases/trackRepository/TrackRepositoryUseCase.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the "RegExp.exec()" method instead.

See more on https://sonarcloud.io/project/issues?id=PackmindHub_packmind&issues=AaDtuQOhSDbY9eKfaGTO&open=AaDtuQOhSDbY9eKfaGTO&pullRequest=529
gitRemoteUrl.trim().match(SCP_LIKE_PATH))?.[1];

Check warning on line 24 in apps/cli/src/application/useCases/trackRepository/TrackRepositoryUseCase.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the "RegExp.exec()" method instead.

See more on https://sonarcloud.io/project/issues?id=PackmindHub_packmind&issues=AaDtuQOhSDbY9eKfaGTP&open=AaDtuQOhSDbY9eKfaGTP&pullRequest=529

// An scp-like remote may name an absolute path: `git@host:/group/repo.git`.
const segments = path
?.replace(/^\/+|\/+$/g, '')

Check warning on line 28 in apps/cli/src/application/useCases/trackRepository/TrackRepositoryUseCase.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=PackmindHub_packmind&issues=AaDtyw7SNffK4ODPu2WT&open=AaDtyw7SNffK4ODPu2WT&pullRequest=529
.replace(/\.git$/i, '')
.split('/');

if (!match) {
if (!segments || segments.length < 2 || segments.some((s) => s === '')) {

Check warning on line 32 in apps/cli/src/application/useCases/trackRepository/TrackRepositoryUseCase.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `.includes()` instead of `.some()` when checking value existence.

See more on https://sonarcloud.io/project/issues?id=PackmindHub_packmind&issues=AaDtuQOhSDbY9eKfaGTR&open=AaDtuQOhSDbY9eKfaGTR&pullRequest=529
throw new Error(`Unable to parse git remote URL: ${gitRemoteUrl}`);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

return {
owner: match[1],
repo: match[2].replace(/\.git$/, ''),
owner: segments.slice(0, -1).join('/'),
repo: segments[segments.length - 1],
Comment thread
greptile-apps[bot] marked this conversation as resolved.
};
}

/**
* Infer the git provider vendor from a remote URL.
*/
function parseProviderVendor(gitRemoteUrl: string): GitProviderVendor {
const normalized = gitRemoteUrl.toLowerCase();
if (normalized.includes('github.com')) {
return 'github';
}
if (normalized.includes('gitlab.com')) {
return 'gitlab';
}
return 'unknown';
}

/**
* Orchestrates setting or moving the tracked repository+branch. Shared by both
* the `track` command and the `init` tracking prompt. Business-only: no console
Expand Down Expand Up @@ -74,7 +72,6 @@
this.gitService.getCurrentBranch(repoPath);
const branch = requestedBranch ?? currentBranch;
const { owner, repo } = parseOwnerRepo(gitRemoteUrl);
const providerVendor = parseProviderVendor(gitRemoteUrl);

// Falling back to the checked-out branch is meaningless with a detached
// HEAD: git names it `HEAD`, and tracking that would record nothing under a
Expand Down Expand Up @@ -199,7 +196,6 @@
repo,
branch,
origin,
providerVendor,
gitRemoteUrl,
});
return { status: 'set', owner, repo, branch, gitRepo };
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { parseOwnerRepo } from './TrackRepositoryUseCase';

describe('parseOwnerRepo', () => {
describe('when the remote has a GitLab subgroup', () => {
it('keeps the whole group path as owner for an HTTPS remote', () => {
expect(
parseOwnerRepo('https://gitlab.com/promyze/sandbox/quentin-nuxt.git'),
).toEqual({ owner: 'promyze/sandbox', repo: 'quentin-nuxt' });
});

it('keeps the whole group path as owner for an scp-like SSH remote', () => {
expect(
parseOwnerRepo('git@gitlab.com:promyze/sandbox/quentin-nuxt.git'),
).toEqual({ owner: 'promyze/sandbox', repo: 'quentin-nuxt' });
});
});

it('parses a plain owner/repo remote', () => {
expect(parseOwnerRepo('https://github.com/owner/repo.git')).toEqual({
owner: 'owner',
repo: 'repo',
});
});

describe('when an scp-like remote names an absolute path', () => {
it('ignores the leading slash', () => {
expect(parseOwnerRepo('git@gitlab.acme.io:/acme/app.git')).toEqual({
owner: 'acme',
repo: 'app',
});
});
});

describe('when the remote has no owner', () => {
it('throws', () => {
expect(() => parseOwnerRepo('https://github.com/repo')).toThrow(
'Unable to parse git remote URL: https://github.com/repo',
);
});
});
});
4 changes: 3 additions & 1 deletion apps/doc/governance/distribution.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,16 @@ When you distribute packages using the CLI, Packmind automatically creates a Git
Connection](/governance/git-repository-connection) for the full setup.
</Warning>

Connecting a provider later does not lose what the CLI recorded. Once a connection exists for the same host — github.com, gitlab.com or your self-managed instance — the CLI records new repositories under it, and adding a repository the CLI already recorded moves it there with its targets and distribution history. The CLI-created entry disappears from your Git settings once it holds no repository.

### When to Use CLI Distribution

The CLI approach is ideal for:

- **CI/CD pipelines** - Automate distribution as part of your deployment process
- **Local development** - Quickly install packages without leaving your terminal
- **Monorepos** - Use `packmind install` to install packages for all `packmind.json` files in the repository
- **Self-hosted Git instances** - Distribute to repositories that aren't connected to the app
- **Repositories without a connection** - Distribute to repositories that aren't connected to the app

## Distribution History and the Tracked Branch

Expand Down
4 changes: 4 additions & 0 deletions apps/doc/governance/git-repository-connection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ GitLab connections use a personal access token:
2. Create a new token with the `api` scope (full API access).
3. Copy your token (it starts with `glpat-`) and paste it into Packmind.

For a self-managed GitLab, enter your instance URL (for example `https://gitlab.acme.io`). Packmind matches repositories to the connection by host, so a clone over HTTPS or SSH reaches the same connection.

## Managing Repository Access

To change which repositories a GitHub App connection can reach, open the connection from the **Connections** tab and use **View Packmind on GitHub**. This opens the Packmind app's page on GitHub, where you can add or remove repositories. The new access takes effect in Packmind without reconnecting.
Expand All @@ -95,6 +97,8 @@ Once you've added your providers, **add repositories** for each provider.

When you add a Git repository, Packmind automatically creates a default target with the root path "/" for that repository. This allows you to immediately start distributing standards and commands to the entire repository. You can later create additional targets for specific paths within the repository if needed.

A repository the CLI already distributed to keeps its history when you add it: it moves under the connection with its existing targets instead of being created again, and the CLI-created entry for that host disappears once it holds no repository.

## Distribution Targets

Before distributing your standards and commands, you can configure targets in **Settings** → **Distribution** → **Targets**. A target defines a specific path within your Git repository where standards and commands will be distributed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,51 @@ describe('TargetResolutionService', () => {
});
});

describe('when the remote is on an instance installed under a path prefix', () => {
const prefixedProvider: GitProviderListItem = {
...githubProvider,
id: createGitProviderId(uuidv4()),
source: GitProviderVendors.gitlab,
url: 'https://devtools.acme.io/gitlab',
};
const otherHostProvider: GitProviderListItem = {
...githubProvider,
id: createGitProviderId(uuidv4()),
source: GitProviderVendors.gitlab,
url: 'https://gitlab.com',
};
const otherHostRepoId = createGitRepoId(uuidv4());

beforeEach(() => {
gitPort.listProviders.mockResolvedValue({
providers: [otherHostProvider, prefixedProvider],
});
gitPort.listRepos.mockImplementation(async (id) => [
gitRepoFactory({
id: id === prefixedProvider.id ? gitRepoId : otherHostRepoId,
owner: 'team',
repo: 'app',
branch: 'main',
}),
]);
targetService.getTargetsByGitRepoId.mockImplementation(async (id) =>
id === gitRepoId ? [target] : [],
);
});

it('returns the target of the repository of that instance', async () => {
const result = await service.findTargetFromGitInfo(
organizationId,
userId,
'https://devtools.acme.io/gitlab/team/app.git',
gitBranch,
'/',
);

expect(result).toEqual(target);
});
});

describe('when no matching repo exists', () => {
beforeEach(() => {
gitPort.listProviders.mockResolvedValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
import { TargetService } from './TargetService';
import { generateTargetName, normalizeRelativePath } from './gitInfoHelpers';
import { IDistributionRepository } from '../../domain/repositories/IDistributionRepository';
import { parseGitRepoInfo, parseGitProviderVendor } from '@packmind/node-utils';
import {
ownerReadingsOf,
parseGitRepoInfo,
parseGitProviderVendor,
} from '@packmind/node-utils';

const origin = 'TargetResolutionService';

Expand Down Expand Up @@ -45,10 +49,15 @@

let gitRepoId: string | null = null;
for (const provider of providersResponse.providers) {
// A provider installed under a path prefix names the group without the
// prefix its remotes carry.
const owners = ownerReadingsOf(owner, provider.url, gitRemoteUrl).map(

Check warning on line 54 in packages/deployments/src/application/services/TargetResolutionService.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`owners` should be a `Set`, and use `owners.has()` to check existence or non-existence.

See more on https://sonarcloud.io/project/issues?id=PackmindHub_packmind&issues=AaDxO4W1iqwq-gAwCcnT&open=AaDxO4W1iqwq-gAwCcnT&pullRequest=529
(reading) => reading.toLowerCase(),
);
const repos = await this.gitPort.listRepos(provider.id);
const matchingRepo = repos.find(
(r) =>
r.owner.toLowerCase() === owner.toLowerCase() &&
owners.includes(r.owner.toLowerCase()) &&
r.repo.toLowerCase() === repo.toLowerCase() &&
r.branch === gitBranch,
);
Expand Down
50 changes: 50 additions & 0 deletions packages/git/src/application/GitProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,16 @@ import {
} from '@packmind/types';
import { GitBranchComparison, GitRepo } from '@packmind/types';
import { OrganizationId, UserId } from '@packmind/types';
import { ownerReadingsOf, sameGitHost } from '@packmind/node-utils';
import { providerHostUrl } from './services/providerHostUrl';
import { v4 as uuidv4 } from 'uuid';

export type OwnerReading = {
owner: string;
// The only provider whose repositories the reading names, null for any.
providerId: GitProviderId | null;
};

export class GitProviderService {
constructor(
private readonly gitProviderRepository: IGitProviderRepository,
Expand Down Expand Up @@ -66,6 +74,48 @@ export class GitProviderService {
return this.gitProviderRepository.deleteById(id, userId);
}

/**
* The owners a remote's group may be recorded under. With the remote, only
* the providers of its host are read; without it, the owner as given names
* a repository of any provider, and a group read without an installation
* path prefix only one of the provider installed under that prefix.
*/
async ownerReadings(
organizationId: OrganizationId,
owner: string,
gitRemoteUrl?: string,
): Promise<OwnerReading[]> {
const providers =
await this.gitProviderRepository.findByOrganizationId(organizationId);
if (gitRemoteUrl) {
return providers
.filter((provider) =>
sameGitHost(providerHostUrl(provider), gitRemoteUrl),
)
.flatMap((provider) =>
ownerReadingsOf(owner, provider.url, gitRemoteUrl).map((reading) => ({
owner: reading,
providerId: provider.id,
})),
);
}
return [
{ owner, providerId: null },
Comment thread
greptile-apps[bot] marked this conversation as resolved.
...providers.flatMap((provider) =>
ownerReadingsOf(owner, provider.url)
.filter((reading) => reading !== owner)
.map((reading) => ({ owner: reading, providerId: provider.id })),
),
];
}

async deleteGitProviderIfEmpty(
id: GitProviderId,
userId: UserId,
): Promise<boolean> {
return this.gitProviderRepository.deleteIfHoldsNoRepository(id, userId);
}

async getAvailableRepos(
gitProviderId: GitProviderId,
page = 1,
Expand Down
1 change: 1 addition & 0 deletions packages/git/src/application/GitRepoService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ describe('GitRepoService', () => {
expect(mockGitRepoRepository.reassignProvider).toHaveBeenCalledWith(
mockGitRepo.id,
newProviderId,
mockGitRepo.owner,
);
});

Expand Down
Loading
Loading