Skip to content

fix: make GitWalkerFs#content handle symlinks correctly#2271

Merged
jcubic merged 5 commits intoisomorphic-git:mainfrom
Andarist:fix/symlink-fs-content
Jan 23, 2026
Merged

fix: make GitWalkerFs#content handle symlinks correctly#2271
jcubic merged 5 commits intoisomorphic-git:mainfrom
Andarist:fix/symlink-fs-content

Conversation

@Andarist
Copy link
Contributor

@Andarist Andarist commented Jan 19, 2026

I'm fixing a bug or typo

  • if this is your first time contributing, run npm run add-contributor and follow the prompts to add yourself to the README
  • squash merge the PR with commit message "fix: [Description of fix]"

Summary by CodeRabbit

  • New Features
    • Symlink handling improved so symlink entries expose their link target as content.
  • Tests
    • Added and updated tests validating symlink target-path content and oid behavior, including for non-existent targets, tree vs workdir comparisons, and submodule walks.
  • Chores
    • Fixture copying updated to preserve symlinks during test setup.
  • Documentation
    • Added a new contributor entry to the contributors list and README.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 19, 2026

📝 Walkthrough

Walkthrough

GitWalkerFs now returns symlink targets (via readlink) as entry content; tests and fixture-copy helpers were updated to preserve and validate symlink behavior. A new contributor, Mateusz Burzyński (Andarist), was added to contributors metadata and README.

Changes

Cohort / File(s) Summary
Contributor metadata
\.all-contributorsrc, README.md
Added Mateusz Burzyński (Andarist) as a contributor with code and test; updated README contributors table.
GitWalker filesystem logic
src/models/GitWalkerFs.js
Detect symlink mode and use readlink() to return the target path as entry content; retain prior read-file + core.autocrlf behavior and caching for non-symlinks.
Symlink tests (repo)
__tests__/test-walk.js
Updated expectations and added tests asserting symlink entries return target path and produce correct oid handling for existent and non-existent targets.
Symlink tests (submodule)
__tests__/test-walk-in-submodule.js
Adjusted expected contents/oids and added tests validating symlink semantics inside submodule walks, including consistency with Git blob hashing.
Fixture copy helpers
__tests__/__helpers__/FixtureFS/makeNodeFixture.js, __tests__/__helpers__/FixtureFSSubmodule.js
Preserve symlinks when copying fixtures by passing verbatimSymlinks: true to recursive copy calls.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Walker as GitWalkerFs
    participant FS as Filesystem (OS)
    participant Cache as Cache
    participant Git as Git object store

    Walker->>Cache: lookup(entry.path)
    alt cached
        Cache-->>Walker: cached entry
    else not cached
        Walker->>FS: stat(entry.path)
        FS-->>Walker: mode
        alt mode is symlink (0o120000)
            Walker->>FS: readlink(entry.path)
            FS-->>Walker: targetPath
            Walker->>Git: hashBlob(targetPath)  %% (hash blob of targetPath string as content)
            Git-->>Walker: blobOid
            Walker->>Cache: store(entry with content=targetPath, oid=blobOid, mode=symlink)
            Walker-->>Caller: entry(content=targetPath, oid=blobOid, mode=symlink)
        else regular file
            Walker->>FS: readFile(entry.path)
            FS-->>Walker: fileContent
            Walker->>Walker: apply core.autocrlf if configured
            Walker->>Git: hashBlob(fileContent)
            Git-->>Walker: blobOid
            Walker->>Cache: store(entry with content=fileContent, oid=blobOid, mode=file)
            Walker-->>Caller: entry(content=fileContent, oid=blobOid, mode=file)
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I nibble links and follow trails,

I read the paths where data sails.
Mateusz hopped in, tests all bright—
Symlinks now speak true by night. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the primary change: fixing GitWalkerFs#content to handle symlinks correctly, which aligns with all modified files and test updates.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

expect(entry.content).toBe('a.txt')
})

it('symlink oid works for non-existent targets', async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran into a crash caused by null content here:

entry._actualSize = content.length

The content method was called by oid method and the stack trace looked like this:

TypeError: Cannot read properties of null (reading 'length')
    at GitWalkerFs.content (/ghcommit/node_modules/.pnpm/[email protected]/node_modules/isomorphic-git/index.cjs:4249:37)
    at /ghcommit/node_modules/.pnpm/[email protected]/node_modules/isomorphic-git/index.cjs:4276:27
    at /ghcommit/node_modules/.pnpm/[email protected]/node_modules/isomorphic-git/index.cjs:1026:16

@sdarwin
Copy link
Contributor

sdarwin commented Jan 19, 2026

If __tests__/test-walk.js is being modified, then __tests__/test-walk-in-submodule.js should also, Usually in an identical way. Copy and paste the modifications. Replace makeFixture with makeFixtureAsSubmodule.

@Andarist
Copy link
Contributor Author

@sdarwin thank you for a promp review, I pushed out the requested change

@jcubic
Copy link
Member

jcubic commented Jan 19, 2026

There are broken tests:

2026-01-19T17:47:29.9253772Z Summary of all failing tests
2026-01-19T17:47:29.9255499Z FAIL __te
2026-01-19T17:47:29.9257418Z sts__/test-wal
2026-01-19T17:47:29.9259065Z k-in-submodu
2026-01-19T17:47:29.9260629Z le.js
2026-01-19T17:47:29.9264808Z   ● w
2026-01-19T17:47:29.9269155Z alk › can pop
2026-01-19T17:47:29.9271059Z ulate type, m
2026-01-19T17:47:29.9272910Z ode, oid, a
2026-01-19T17:47:29.9274734Z nd content
2026-01-19T17:47:29.9276355Z 
2026-01-19T17:47:29.9313695Z 
2026-01-19T17:47:29.9338481Z     expect
2026-01-19T17:47:29.9339319Z (received).t
2026-01-19T17:47:29.9339919Z oEqual(expe
2026-01-19T17:47:29.9340320Z cted) // dee
2026-01-19T17:47:29.9340675Z p equality
2026-01-19T17:47:29.9340848Z 
2026-01-19T17:47:29.9341015Z 
2026-01-19T17:47:29.9341347Z     - Expect
2026-01-19T17:47:29.9341686Z ed  - 2
2026-01-19T17:47:29.9342028Z    
2026-01-19T17:47:29.9342371Z  + Received 
2026-01-19T17:47:29.9342710Z  + 2
2026-01-19T17:47:29.9342869Z 
2026-01-19T17:47:29.9343207Z     @
2026-01-19T17:47:29.9343592Z @ -177,14 +
2026-01-19T17:47:29.9343966Z 177,14 @@
2026-01-19T17:47:29.9344476Z  
2026-01-19T17:47:29.9344811Z          nu
2026-01-19T17:47:29.9345136Z ll,
2026-01-19T17:47:29.9345463Z         
2026-01-19T17:47:29.9345791Z ],
2026-01-19T17:47:29.9346191Z         A
2026-01-19T17:47:29.9346789Z rray [
2026-01-19T17:47:29.9347220Z     
2026-01-19T17:47:29.9347621Z       "fold
2026-01-19T17:47:29.9348023Z er/3.txt",
2026-01-19T17:47:29.9348234Z 
2026-01-19T17:47:29.9348602Z           Ob
2026-01-19T17:47:29.9348984Z ject {
2026-01-19T17:47:29.9349395Z     
2026-01-19T17:47:29.9349802Z -       "content": "",
2026-01-19T17:47:29.9350304Z     +       "content": "/home/vsts/work/1/s/__tests__/__fixtures__/test-walk/folder/1.txt",
2026-01-19T17:47:29.9350800Z             "hasStat": true,
2026-01-19T17:47:29.9351263Z             "mode": 40960,
2026-01-19T17:47:29.9351490Z 
2026-01-19T17:47:29.9351882Z  
2026-01-19T17:47:29.9363765Z    -       "oid": "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
2026-01-19T17:47:29.9364325Z     +       "oid": "f5932db33eee46f579d534f94e1c2f3c5f68533f",
2026-01-19T17:47:29.9364749Z             "type": "blob",
2026-01-19T17:47:29.9365090Z           },
2026-01-19T17:47:29.9365430Z           null,
2026-01-19T17:47:29.9365765Z           Object {
2026-01-19T17:47:29.9366132Z             "content": undefined,
2026-01-19T17:47:29.9366318Z 
2026-01-19T17:47:29.9367010Z     �[0m �[90m 85 |�[39m       ]�[33m,�[39m
2026-01-19T17:47:29.9367457Z      �[90m 86 |�[39m     })
2026-01-19T17:47:29.9368048Z     �[31m�[1m>�[22m�[39m�[90m 87 |�[39m     expect(matrix)�[33m.�[39mtoEqual([
2026-01-19T17:47:29.9368574Z      �[90m    |�[39m                    �[31m�[1m^�[22m�[39m
2026-01-19T17:47:29.9369027Z      �[90m 88 |�[39m       [
2026-01-19T17:47:29.9369543Z      �[90m 89 |�[39m         �[32m'.'�[39m�[33m,�[39m
2026-01-19T17:47:29.9370371Z      �[90m 90 |�[39m         {�[0m
2026-01-19T17:47:29.9370650Z 
2026-01-19T17:47:29.9371161Z       at Object.toEqual (__tests__/test-walk-in-submodule.js:87:20)
2026-01-19T17:47:29.9371465Z 
2026-01-19T17:47:29.9372004Z   ● walk › autocrlf respected when gitconfig changes
2026-01-19T17:47:29.9372298Z 
2026-01-19T17:47:29.9372747Z     expect(received).toEqual(expected) // deep equality
2026-01-19T17:47:29.9373027Z 
2026-01-19T17:47:29.9373413Z     - Expected  - 2
2026-01-19T17:47:29.9373834Z     + Received  + 2
2026-01-19T17:47:29.9374055Z 
2026-01-19T17:47:29.9374448Z     @@ -177,14 +177,14 @@
2026-01-19T17:47:29.9374868Z           null,
2026-01-19T17:47:29.9375249Z         ],
2026-01-19T17:47:29.9375635Z         Array [
2026-01-19T17:47:29.9376024Z           "folder/3.txt",
2026-01-19T17:47:29.9376599Z           Object {
2026-01-19T17:47:29.9377047Z     -       "content": "",
2026-01-19T17:47:29.9377558Z     +       "content": "/home/vsts/work/1/s/__tests__/__fixtures__/test-walk/folder/1.txt",
2026-01-19T17:47:29.9378038Z    
2026-01-19T17:47:29.9383740Z          "hasStat": true,
2026-01-19T17:47:29.9384221Z             "mode": 40960,
2026-01-19T17:47:29.9384637Z     -       "oid": "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
2026-01-19T17:47:29.9385136Z     +       "oid": "f5932db33eee46f579d534f94e1c2f3c5f68533f",
2026-01-19T17:47:29.9385575Z             "type": "blob",
2026-01-19T17:47:29.9385945Z           },
2026-01-19T17:47:29.9386319Z           null,
2026-01-19T17:47:29.9386867Z           Object {
2026-01-19T17:47:29.9387269Z             "content": undefined,
2026-01-19T17:47:29.9387459Z 
2026-01-19T17:47:29.9387945Z     �[0m �[90m 511 |�[39m       ]�[33m,�[39m
2026-01-19T17:47:29.9388383Z      �[90m 512 |�[39m     ]
2026-01-19T17:47:29.9388937Z     �[31m�[1m>�[22m�[39m�[90m 513 |�[39m     expect(matrix)�[33m.�[39mtoEqual(expectedMatrix)
2026-01-19T17:47:29.9389474Z      �[90m     |�[39m                    �[31m�[1m^�[22m�[39m
2026-01-19T17:47:29.9389944Z      �[90m 514 |�[39m
2026-01-19T17:47:29.9390526Z      �[90m 515 |�[39m     �[90m// Check oid + content updates when changing autocrlf to true�[39m
2026-01-19T17:47:29.9391188Z      �[90m 516 |�[39m     �[36mawait�[39m setConfig({�[0m
2026-01-19T17:47:29.9391469Z 
2026-01-19T17:47:29.9392122Z       at Object.toEqual (__tests__/test-walk-in-submodule.js:513:20)
2026-01-19T17:47:29.9392440Z 
2026-01-19T17:47:29.9392852Z FAIL __tests__/test-walk.js
2026-01-19T17:47:29.9393464Z   ● walk › can populate type, mode, oid, and content
2026-01-19T17:47:29.9393751Z 
2026-01-19T17:47:29.9394196Z     expect(received).toEqual(expected) // deep equality
2026-01-19T17:47:29.9394474Z 
2026-01-19T17:47:29.9394860Z     - Expected  - 2
2026-01-19T17:47:29.9395276Z     + Received  + 2
2026-01-19T17:47:29.9395493Z 
2026-01-19T17:47:29.9395899Z     @@ -164,14 +164,14 @@
2026-01-19T17:47:29.9396313Z           null,
2026-01-19T17:47:29.9396880Z         ],
2026-01-19T17:47:29.9397299Z         Array [
2026-01-19T17:47:29.9397718Z           "folder/3.txt",
2026-01-19T17:47:29.9398144Z           Object {
2026-01-19T17:47:29.9398552Z     -       "content": "",
2026-01-19T17:47:29.9399053Z     +       "content": "/home/vsts/work/1/s/__tests__/__fixtures__/test-walk/folder/1.txt",
2026-01-19T17:47:29.9399557Z             "hasStat": true,
2026-01-19T17:47:29.9399983Z             "mode": 40960,
2026-01-19T17:47:29.9400452Z     -       "oid": "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
2026-01-19T17:47:29.9400956Z     +       "oid": "f5932db33eee46f579d534f94e1c2f3c5f68533f",
2026-01-19T17:47:29.9401416Z             "type": "blob",
2026-01-19T17:47:29.9401804Z           },
2026-01-19T17:47:29.9402201Z           null,
2026-01-19T17:47:29.9402589Z           Object {
2026-01-19T17:47:29.9403005Z             "content": undefined,
2026-01-19T17:47:29.9403233Z 
2026-01-19T17:47:29.9403745Z     �[0m �[90m 82 |�[39m       ]�[33m,�[39m
2026-01-19T17:47:29.9404237Z      �[90m 83 |�[39m     })
2026-01-19T17:47:29.9404847Z     �[31m�[1m>�[22m�[39m�[90m 84 |�[39m     expect(matrix)�[33m.�[39mtoEqual([
2026-01-19T17:47:29.9405645Z      �[90m    |�[39m                    �[31m�[1m^�[22m�[39m
2026-01-19T17:47:29.9406144Z      �[90m 85 |�[39m       [
2026-01-19T17:47:29.9406832Z      �[90m 86 |�[39m         �[32m'.'�[39m�[33m,�[39m
2026-01-19T17:47:29.9407413Z      �[90m 87 |�[39m         {�[0m
2026-01-19T17:47:29.9407641Z 
2026-01-19T17:47:29.9408109Z       at Object.toEqual (__tests__/test-walk.js:84:20)
2026-01-19T17:47:29.9408365Z 
2026-01-19T17:47:29.9408934Z   ● walk › autocrlf respected when gitconfig changes
2026-01-19T17:47:29.9409234Z 
2026-01-19T17:47:29.9409668Z     expect(received).toEqual(expected) // deep equality
2026-01-19T17:47:29.9409916Z 
2026-01-19T17:47:29.9410317Z     - Expected  - 2
2026-01-19T17:47:29.9410742Z     + Received  + 2
2026-01-19T17:47:29.9410958Z 
2026-01-19T17:47:29.9411355Z     @@ -164,14 +164,14 @@
2026-01-19T17:47:29.9411763Z           null,
2026-01-19T17:47:29.9412153Z         ],
2026-01-19T17:47:29.9412537Z         Array [
2026-01-19T17:47:29.9412949Z           "folder/3.txt",
2026-01-19T17:47:29.9413352Z           Object {
2026-01-19T17:47:29.9413753Z     -       "content": "",
2026-01-19T17:47:29.9414235Z     +       "content": "/home/vsts/work/1/s/__tests__/__fixtures__/test-walk/folder/1.txt",
2026-01-19T17:47:29.9414723Z             "hasStat": true,
2026-01-19T17:47:29.9415132Z             "mode": 40960,
2026-01-19T17:47:29.9415587Z     -       "oid": "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
2026-01-19T17:47:29.9416102Z     +       "oid": "f5932db33eee46f579d534f94e1c2f3c5f68533f",
2026-01-19T17:47:29.9416739Z             "type": "blob",
2026-01-19T17:47:29.9417160Z           },
2026-01-19T17:47:29.9417559Z           null,
2026-01-19T17:47:29.9417964Z           Object {
2026-01-19T17:47:29.9418373Z             "content": undefined,
2026-01-19T17:47:29.9418613Z 
2026-01-19T17:47:29.9419139Z     �[0m �[90m 483 |�[39m       ]�[33m,�[39m
2026-01-19T17:47:29.9419649Z      �[90m 484 |�[39m     ]
2026-01-19T17:47:29.9420292Z     �[31m�[1m>�[22m�[39m�[90m 485 |�[39m     expect(matrix)�[33m.�[39mtoEqual(expectedMatrix)
2026-01-19T17:47:29.9420922Z      �[90m     |�[39m                    �[31m�[1m^�[22m�[39m
2026-01-19T17:47:29.9421420Z      �[90m 486 |�[39m
2026-01-19T17:47:29.9422041Z      �[90m 487 |�[39m     �[90m// Check oid + content updates when changing autocrlf to true�[39m
2026-01-19T17:47:29.9422838Z      �[90m 488 |�[39m     �[36mawait�[39m setConfig({�[0m
2026-01-19T17:47:29.9423165Z 
2026-01-19T17:47:29.9423617Z       at Object.toEqual (__tests__/test-walk.js:485:20)
2026-01-19T17:47:29.9423893Z 
2026-01-19T17:47:29.9424082Z 
2026-01-19T17:47:29.9424512Z Test Suites: 2 failed, 172 passed, 174 total
2026-01-19T17:47:29.9424994Z Tests:       4 failed, 10 skipped, 1210 passed, 1224 total
2026-01-19T17:47:29.9425471Z Snapshots:   190 passed, 190 total
2026-01-19T17:47:29.9425906Z Time:        208.415 s
2026-01-19T17:47:29.9426566Z Ran all test suites.
2026-01-19T17:47:29.9426900Z 
2026-01-19T17:47:29.9617523Z Failed with exit code: 1
2026-01-19T17:47:29.9624347Z Fatal Error: Failed with exit code: 1
2026-01-19T17:47:29.9673168Z The script called "test.node" which runs "export NODE_OPTIONS="--experimental-vm-modules --max-old-space-size-percentage=80" (timeout -t 15m -- jest --ci --coverage) || (timeout -t 15m -- jest --ci --coverage) || (timeout -t 15m -- jest --ci --coverage)" failed with exit code 255 https://github.com/sezna/nps/blob/master/other/ERRORS_AND_WARNINGS.md#failed-with-exit-code
2026-01-19T17:47:29.9730791Z The script called "test" which runs "nps lint && nps build.test && nps test.typecheck && nps test.setup && nps test.node && nps test.chrome && nps test.teardown" failed with exit code 255 https://github.com/sezna/nps/blob/master/other/ERRORS_AND_WARNINGS.md#failed-with-exit-code
2026-01-19T17:47:29.9849298Z 
2026-01-19T17:47:29.9887748Z ##[error]Bash exited with code '255'.
2026-01-19T17:47:29.9925651Z ##[section]Finishing: Run tests

@Andarist
Copy link
Contributor Author

@jcubic initially I ran into some classic jest/esm/node/whatever issues when trying to run tests locally so I couldn't quickly verify everything. I solved this and was able to investigate and fix those failures.

It turns out that:

  • symlink targets were converted to absolute paths when directories were copied but a better thing here is to use verbatimSymlinks: true
  • some tests were actually already expecting the symlink's target's content instead of the target's path - so some expected oids/contents were incorrect

@jcubic
Copy link
Member

jcubic commented Jan 22, 2026

Can you explain why you don't read the target of the symlink?

What is the use of the walk function if it doesn't return the content of the file, only a path? If you run the walk on the code base, you will need to check if the content is path or file content.

Isn't it supposed to be something like:

        let filepath = `${dir}/${entry._fullpath}`;
        if ((await entry.mode()) >> 12 === 0b1010) {
          filepath = await fs.readlink(filepath);
        }
        const config = await this._getGitConfig(fs, gitdir)
        const autocrlf = await config.get('core.autocrlf')
        const content = await fs.read(filepath, { autocrlf })

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@__tests__/test-walk-in-submodule.js`:
- Around line 592-649: Replace the hardcoded symlink mode 0o120000 in both
tests' assertions (the expect(entry.mode).toBe(0o120000) lines) with the
BrowserFS-aware mode value computed earlier in this file (the same
helper/variable used above to adapt for BrowserFS’ 555/exec-bit quirk), so both
symlink-mode assertions use that shared expected-mode variable instead of the
literal 0o120000.

@Andarist
Copy link
Contributor Author

What is the use of the walk function if it doesn't return the content of the file, only a path? If you run the walk on the code base, you will need to check if the content is path or file content.

It's about:

  • git parity (git cat-file blob <symlink-oid> behavior)
  • parity with the TREE walker (workdir.content === tree.content)
  • oid === shasum(content) invariant

From what I understand, .content is not meant to be a convenience method to get the content of the file but rather the content of the git-stored object.

I added new tests to demo what I mean.

@jcubic
Copy link
Member

jcubic commented Jan 23, 2026

ok, make sense.

@jcubic jcubic merged commit 667be27 into isomorphic-git:main Jan 23, 2026
4 checks passed
@isomorphic-git-bot
Copy link
Member

🎉 This PR is included in version 1.36.2 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

Comments