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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ The action adds files using a regular `git add` command, so you can put every ki
The script will not stop if one of the git commands doesn't match any file. E.g.: if your command shows a "fatal: pathspec 'yourFile' did not match any files" error the action will go on, unless specified otherwise with `pathspec_error_handling`.
You can also use JSON or YAML arrays (e.g. `'["first", "second"]'`, `"['first', 'second']"`) to make the action run multiple `git add` commands: the action will log how your input has been parsed. Please mind that your input still needs to be a string because of how GitHub Actions works with inputs: just write your array inside the string, the action will parse it later.

The action refuses to commit if `git add` would introduce a **new gitlink** (mode `160000`) — for example when a directory contains its own nested `.git` folder. Git would otherwise record that path as an embedded repository reference rather than its files, often with only a silent warning. Remove the nested `.git` directory, or unstage the path with `git rm --cached -- <path>`, before committing. Updates to **existing** submodules (already tracked as gitlinks) are still allowed.

### Deleting files

The `remove` option can be used if a predetermined list of files needs to be removed. It runs the `git rm` command, so you can pass every kind of argument with it. As if with the [`add` input](#adding-files), you can also use JSON or YAML arrays to make the action run multiple `git rm` commands.
Expand Down
2 changes: 1 addition & 1 deletion lib/index.js

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import * as path from 'path';
import simpleGit, {Response} from 'simple-git';
import {checkInputs, getInput, logOutputs, setOutput} from './io';
import {
assertNoUnexpectedGitlinks,
findUnexpectedGitlinks,
log,
matchGitArgs,
parseInputArray,
Expand Down Expand Up @@ -272,6 +274,9 @@ async function add(ignoreErrors: 'all' | 'pathspec' | 'none' = 'none') {
);
}

const cachedRaw = await git.raw(['diff', '--cached', '--raw']);
assertNoUnexpectedGitlinks(findUnexpectedGitlinks(cachedRaw));

return res;
}

Expand Down
41 changes: 41 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,47 @@ export function matchGitArgs(string: string) {
return parsed;
}

/**
* Parses `git diff --cached --raw` output and returns paths that would introduce
* a new gitlink (mode 160000) — i.e. an embedded git repository staged as if it
* were a submodule. Updates that stay 160000→160000 (existing submodule bumps)
* are allowed.
*
* Raw line shape: `:oldmode newmode oldsha newsha status\tpath`
* Rename/copy: `:oldmode newmode oldsha newsha status\toldpath\tnewpath`
* Status may include a score (e.g. `R100`, `C75`).
*/
export function findUnexpectedGitlinks(diffCachedRaw: string): string[] {
const paths: string[] = [];
for (const line of diffCachedRaw.split('\n')) {
if (!line.startsWith(':')) continue;
const match = line.match(
/^:(\d{6}) (\d{6}) [0-9a-f]+ [0-9a-f]+ [A-Z]\d*\t([^\t]+)(?:\t(.+))?$/,
);
if (!match) continue;
const [, oldMode, newMode, srcPath, dstPath] = match;
if (newMode === '160000' && oldMode !== '160000') {
paths.push(dstPath ?? srcPath);
}
}
return paths;
}

/**
* Fails the action if `git add` staged any unexpected gitlinks (embedded repos).
*/
export function assertNoUnexpectedGitlinks(paths: string[]): void {
if (paths.length === 0) return;

const listed = paths.map(p => ` - ${p}`).join('\n');
const rmHints = paths.map(p => ` git rm --cached -- ${p}`).join('\n');
throw new Error(
`Refusing to commit unexpected gitlink(s) (embedded git repository staged as mode 160000):\n${listed}\n` +
'Git records a nested .git directory as a gitlink, not as its files. ' +
`Remove the nested .git directory, or unstage the path(s) with:\n${rmHints}`,
);
}

/**
* Tries to parse a YAML sequence (which can be a JSON array).
* If it fails, it returns an array containing the input value as its only element.
Expand Down
86 changes: 86 additions & 0 deletions test/util.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
assertNoUnexpectedGitlinks,
assertValidBranchName,
findUnexpectedGitlinks,
matchGitArgs,
parseInputArray,
pickGitIdentityConfig,
Expand Down Expand Up @@ -290,3 +292,87 @@ describe('pickGitIdentityConfig', () => {
).toStrictEqual({});
});
});

describe('findUnexpectedGitlinks', () => {
it('returns empty for empty or unrelated output', () => {
expect(findUnexpectedGitlinks('')).toStrictEqual([]);
expect(
findUnexpectedGitlinks(
':000000 100644 0000000000000000000000000000000000000000 abcdefabcdefabcdefabcdefabcdefabcdefabcd A\tREADME.md',
),
).toStrictEqual([]);
});

it('flags a newly added gitlink', () => {
expect(
findUnexpectedGitlinks(
':000000 160000 0000000000000000000000000000000000000000 8ec20b7a40813dbf999aa0c19053ccfe3a72cdd5 A\tevil_nested_repo',
),
).toStrictEqual(['evil_nested_repo']);
});

it('flags a mode change into a gitlink', () => {
expect(
findUnexpectedGitlinks(
':100644 160000 abcdefabcdefabcdefabcdefabcdefabcdefabcd 8ec20b7a40813dbf999aa0c19053ccfe3a72cdd5 T\twas_a_file',
),
).toStrictEqual(['was_a_file']);
});

it('allows existing submodule SHA updates (160000→160000)', () => {
expect(
findUnexpectedGitlinks(
':160000 160000 abcdefabcdefabcdefabcdefabcdefabcdefabcd 8ec20b7a40813dbf999aa0c19053ccfe3a72cdd5 M\tvendor/lib',
),
).toStrictEqual([]);
});

it('flags rename/copy into a gitlink and uses the destination path', () => {
expect(
findUnexpectedGitlinks(
':100644 160000 abcdefabcdefabcdefabcdefabcdefabcdefabcd 8ec20b7a40813dbf999aa0c19053ccfe3a72cdd5 R100\told_name\tnested_renamed',
),
).toStrictEqual(['nested_renamed']);
expect(
findUnexpectedGitlinks(
':100644 160000 abcdefabcdefabcdefabcdefabcdefabcdefabcd 8ec20b7a40813dbf999aa0c19053ccfe3a72cdd5 C75\tsrc_file\tnested_copied',
),
).toStrictEqual(['nested_copied']);
});

it('allows rename/copy that stays a gitlink (160000→160000)', () => {
expect(
findUnexpectedGitlinks(
':160000 160000 abcdefabcdefabcdefabcdefabcdefabcdefabcd 8ec20b7a40813dbf999aa0c19053ccfe3a72cdd5 R100\told_sub\tnew_sub',
),
).toStrictEqual([]);
});

it('collects multiple unexpected gitlinks among mixed changes', () => {
const raw = [
':000000 100644 0000000000000000000000000000000000000000 abcdefabcdefabcdefabcdefabcdefabcdefabcd A\tok.txt',
':000000 160000 0000000000000000000000000000000000000000 8ec20b7a40813dbf999aa0c19053ccfe3a72cdd5 A\tnested_a',
':160000 160000 abcdefabcdefabcdefabcdefabcdefabcdefabcd fedcbafedcbafedcbafedcbafedcbafedcbafedc M\tok_submodule',
':040000 160000 abcdefabcdefabcdefabcdefabcdefabcdefabcd 8ec20b7a40813dbf999aa0c19053ccfe3a72cdd5 T\tnested_b',
].join('\n');
expect(findUnexpectedGitlinks(raw)).toStrictEqual(['nested_a', 'nested_b']);
});
});

describe('assertNoUnexpectedGitlinks', () => {
it('does nothing when there are no paths', () => {
expect(() => assertNoUnexpectedGitlinks([])).not.toThrow();
});

it('throws with path names and remediation hints', () => {
expect(() => assertNoUnexpectedGitlinks(['evil_nested_repo'])).toThrow(
/unexpected gitlink/,
);
expect(() => assertNoUnexpectedGitlinks(['evil_nested_repo'])).toThrow(
/evil_nested_repo/,
);
expect(() => assertNoUnexpectedGitlinks(['evil_nested_repo'])).toThrow(
/git rm --cached -- evil_nested_repo/,
);
});
});