Skip to content

Run a mirror when its configuration is updated#1247

Merged
ikhoon merged 10 commits intoline:mainfrom
ikhoon:fix-mirror-state
Feb 3, 2026
Merged

Run a mirror when its configuration is updated#1247
ikhoon merged 10 commits intoline:mainfrom
ikhoon:fix-mirror-state

Conversation

@ikhoon
Copy link
Contributor

@ikhoon ikhoon commented Jan 19, 2026

Motivation:

See #1183 for details.

Modifications:

  • Add remoteRevision, remotePath, localRevision and localPath fields to .mirror_state.json to capture the configuration used when a mirror is run.
  • Move RepositoryUri to public API and expose it in Mirror.java to allow access to the full remote URL in mirror tasks.
  • Set the depth of Git fetch to 2 to read parent commit.
    • This is required to detect changes made on the remote in local-to-remote mode.
  • If mirrored data becomes dirty due to a manual push, it is overwritten in the next mirror job.

Result:

Motivation:

See line#1183 for details.

Modifications:

- Add `remoteRevision`, `remotePath`, `localRevision` and `localPath`
  fields to `.mirror_state.json` to capture the configuration used when
  a mirror is run.
- Move `RepositoryUri` to public API and expose it in `Mirror.java` to
  allow access to the full remote URL in mirror tasks.
- Set the depth of Git fetch to 2 to read parent commit.
  - This is required to detect changes made on the remote in
    local-to-remote mode.

Result:

- Git mirroring now runs correctly when the configuration changes or the
  mirrored content is manually overridden.
- Closes line#1183
@ikhoon ikhoon added this to the 0.80.0 milestone Jan 19, 2026
@ikhoon ikhoon added the defect label Jan 19, 2026
@coderabbitai
Copy link

coderabbitai bot commented Jan 19, 2026

📝 Walkthrough

Walkthrough

Unifies remote identification into RepositoryUri, expands MirrorState to track per-path remote/local revisions and configHash, refactors Git mirror decision and state logic (fetch depth, previous-commit handling, RUN/SKIP/COMPARE decisions), and updates providers and tests to the new API and per-path expectations.

Changes

Cohort / File(s) Summary
Core mirror model & API
server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/AbstractMirror.java, server/src/main/java/com/linecorp/centraldogma/server/mirror/RepositoryUri.java, server/src/main/java/com/linecorp/centraldogma/server/mirror/Mirror.java
Replace separate remote fields (URI/path/branch) with RepositoryUri; add remoteUri() accessor and cached hashString(); remove legacy getters; update toString and jitter logic.
Git mirror implementation
server-mirror-git/src/main/java/.../AbstractGitMirror.java, server-mirror-git/src/main/java/.../DefaultGitMirror.java, server-mirror-git/src/main/java/.../SshGitMirror.java
Constructors now accept RepositoryUri; introduce MirrorState-driven decision flow (MirrorDecision enum, shouldRunRemoteToLocal/shouldRunLocalToRemote), increase fetch depth to 2, compute previous commit parent, refine change detection, and update mirror_state.json handling.
Mirror state model
server/src/main/java/com/linecorp/centraldogma/server/internal/mirror/MirrorState.java
Expand MirrorState to include remoteRevision, localRevision, direction, configHash; add accessors, previousTargetRevision(), equals/hashCode/toString; make constructor public with JSON mapping.
Providers & construction sites
server-mirror-git/src/main/java/.../GitMirrorProvider.java, server/src/main/java/.../CentralDogmaMirror.java, server/src/main/java/.../CentralDogmaMirrorProvider.java, server/src/main/java/.../MirrorConfig.java
Update providers and constructor call-sites to pass RepositoryUri instead of separate uri/path/branch; adjust imports and argument lists.
Integration & unit tests
it/mirror/src/test/java/.../git/GitMirrorIntegrationTest.java, it/mirror/src/test/java/.../git/LocalToRemoteGitMirrorTest.java, it/mirror-listener/src/test/java/.../CustomMirrorListenerTest.java, it/mirror/src/test/java/.../git/MirrorRunnerTest.java, server-mirror-git/src/test/java/.../MirrorSchedulingServiceTest.java
Update tests to construct RepositoryUri and adapt assertions; add helpers/tests for per-path mirror mappings and path-change triggers; update expected mirror_state.json contents and some assertion strings.
Scheduling & minor tests
server-mirror-git/src/test/java/.../MirrorSchedulingServiceTest.java, it/mirror-listener/src/test/java/.../CustomMirrorListenerTest.java
Replace test usages of URI with RepositoryUri.parse(...) for AbstractMirror instantiation; minor assertion text updates.

Sequence Diagram(s)

sequenceDiagram
    participant Scheduler as Scheduler/Invoker
    participant Mirror as AbstractGitMirror
    participant Local as LocalRepo
    participant Remote as Remote Git/Repo
    participant State as mirror_state.json

    Scheduler->>Mirror: trigger mirrorLocalToRemote()
    activate Mirror
    Mirror->>Local: read head commit and parent (fetch depth=2)
    Mirror->>Remote: fetch head commit (depth=2)
    Mirror->>Mirror: localCurrentMirrorState(mirror_state.json)
    Mirror->>Mirror: remoteCurrentMirrorState(remote tree)
    Mirror->>Mirror: shouldRunLocalToRemote(oldState, localHead, previousRemoteCommit)
    alt Decision = RUN / COMPARE_AND_RUN
        Mirror->>Remote: push per-path changes
        Mirror->>State: write updated mirror_state.json (per-path + configHash)
        Mirror-->>Scheduler: mirror completed (updated)
    else Decision = SKIP
        Mirror-->>Scheduler: skip — up-to-date
    end
    deactivate Mirror
Loading
sequenceDiagram
    participant Scheduler as Scheduler/Invoker
    participant Mirror as AbstractGitMirror
    participant Remote as Remote Git/Repo
    participant Local as LocalRepo
    participant State as mirror_state.json

    Scheduler->>Mirror: trigger mirrorRemoteToLocal()
    activate Mirror
    Mirror->>Remote: fetch head commit (depth=2)
    Mirror->>Mirror: remoteCurrentMirrorState(remote tree)
    Mirror->>Local: read local mirror_state.json
    Mirror->>Mirror: shouldRunRemoteToLocal(oldState, previousLocalHead, remoteCommit)
    alt Decision = RUN / COMPARE_AND_RUN
        Mirror->>Local: apply remote per-path changes
        Mirror->>State: write updated mirror_state.json (per-path + configHash)
        Mirror-->>Scheduler: mirror completed (updated)
    else Decision = SKIP
        Mirror-->>Scheduler: skip — up-to-date
    end
    deactivate Mirror
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Support for YAML #1229 — Modifies the git mirror implementation (AbstractGitMirror); related to state/remote handling and may overlap with per-path mirror changes.

Suggested reviewers

  • trustin
  • minwoox
  • jrhee17

Poem

🐰 I nibbled paths and hashed each tune,
RepoUris glint beneath the moon,
States now know which route to trace,
Per-path mirrors find their place,
I thumped my feet — replication's in bloom!

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.04% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Run a mirror when its configuration is updated' clearly and accurately summarizes the main change: enabling mirror operations to execute when configuration parameters change, which aligns with the PR's core objective of addressing issue #1183.
Description check ✅ Passed The description provides relevant context linking to issue #1183, clearly explains the modifications made (adding fields to mirror_state.json, moving RepositoryUri to public API, adjusting Git fetch depth, and handling manual overrides), and describes the expected results.
Linked Issues check ✅ Passed The PR successfully addresses issue #1183 by adding remoteRevision, localRevision, remotePath, and localPath fields to mirror_state.json to detect configuration changes, exposing RepositoryUri in the Mirror interface, and implementing decision logic to re-run mirrors when configuration differs from stored state.
Out of Scope Changes check ✅ Passed All code changes are directly related to the PR objectives: updating mirror state tracking, refactoring remote URI handling via RepositoryUri, implementing mirror decision logic, and updating test cases to validate the new per-path mirroring behavior. No out-of-scope changes detected.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

@ikhoon ikhoon marked this pull request as draft January 19, 2026 07:53
@ikhoon ikhoon marked this pull request as ready for review January 20, 2026 07:35
Copy link
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

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

👍 👍

*
* @deprecated Use {@link #remoteRevision()} or {@link #localRevision()} instead.
*/
@Deprecated
Copy link
Contributor

Choose a reason for hiding this comment

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

nit; the proposed logic always gets the previous revision of the target (local for remote2local, remote for local2remote).

It might be easier to understand if a separate previousTargetRevision() method is added.

}
}

boolean shouldRunMirror(@Nullable MirrorState oldMirrorState, Revision localHead,
Copy link
Contributor

Choose a reason for hiding this comment

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

Question) If there are multiple mirrors configured for a single repository, even if a file in directory A is changed will all mirrors be triggered due to also looking at the target revision?

I wonder if adding a hash of the mirror configuration for each mirror to MirrorState is a simpler/safer approach

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is an important point. I hadn't considered that multiple mirrors can be configured for a single repository.

if adding a hash of the mirror configuration for each mirror to MirrorState is a simpler/safer approach

A mirror configuration hash alone can't tell us whether we should run the mirror when there has been a manual push to the local repo. However, it would be worth adding the hash to detect any changes in mirror configuration.

Although, the logic is complex, the following approach seems to be the most appropriate under the Central Dogma revision model which does not provide the HEAD revision for a directory or a file.

Proposal: For remote-to-local mirroring, it should be safe to rely on the local HEAD when the local path is the root (/). If the local path is non-root (e.g, /subdir) and previousLocalRevision + 1 doesn't match the current HEAD, we could run repository.diff(previousLocalRevision, headRevision, "/subdir") to check whether there are actual changes happened before running a mirror task.

Copy link
Contributor

Choose a reason for hiding this comment

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

if adding a hash of the mirror configuration for each mirror to MirrorState is a simpler/safer approach

I interpreted the original issue as mirror doesn't run properly when the path (MirrorState) changes. I didn't think it was necessary that the revision of the target is considered for mirroring.

Whatever approach is taken, I'm fine as long as it's consistent for both directions.

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 made new changes to properly overwrite a manual push in the next mirror job. PTAL.

@ikhoon ikhoon requested a review from jrhee17 January 29, 2026 06:57
Copy link
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

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

👍 👍

return MirrorDecision.RUN;
}

if (!localHead.text().equals(previousLocalRevision)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Question) I may have not understood as the logic is complex, but is localHead ever equal to previousLocalRevision given that

  • localHead is the current HEAD revision
  • previousLocalRevision is from the MirrorState, which means it is the previous local revision?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Local-to-remote mirroring pushes a new commit that includes mirror_state.json to the remote repository which does not increase the local head revision. If no commits are pushed to either the local or remote repository, previousLocalRevision equals localHead in next mirroring jobs.

@JsonCreator
MirrorState(@JsonProperty(value = "sourceRevision", required = true) String sourceRevision) {
public MirrorState(@JsonProperty(value = "sourceRevision", required = true) String sourceRevision,
@JsonProperty("remoteRevision") @Nullable String remoteRevision,
Copy link
Contributor

Choose a reason for hiding this comment

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

Understood that:

  • For localToRemote
    • remoteRevision is the revision before the mirror push (so the previous version)
    • localRevision is the previous revision before mirror push
  • For remoteToLocal
    • remoteRevision is the current revision (since no push is done to remote)
    • localRevision is the previous revision before mirror push

It might be worth documenting this somewhere since I don't think this is well expressed in the currently proposed code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

remoteRevision and localRevision represent the revisions at the time a mirroring job updates a change.

  • For local-to-remove, a new commit is pushed to the remote repository.
    • remoteRevision indicates the previous head revision (e.g, commit sha236)
    • localRevision still equals to the local HEAD.
  • For remote-to-local, a new commit is pushed to the local repository.
    • localRevision indicates the previous head revision (1234)
    • remoteRevision still equals to the remote HEAD.

I will document this behavior in the source code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server-mirror-git/src/main/java/com/linecorp/centraldogma/server/internal/mirror/AbstractGitMirror.java (1)

705-792: ⚠️ Potential issue | 🟠 Major

Empty-content updates can be missed in compare mode.
hasChanges is set only when contentLength > 0. If a file changes to empty content (length 0), applyPathEdit returns 0, so hasChanges stays false and COMPARE_AND_RUN may incorrectly skip despite real changes. Use a sentinel for “no change” or a boolean.

🐛 Proposed fix (distinguish “no change” vs empty content)
-            final long contentLength = applyPathEdit(dirCache, inserter, pathString, entry, oldContent);
-            if (contentLength > 0) {
-                hasChanges = true;
-            }
-            numBytes += contentLength;
+            final long contentLength = applyPathEdit(dirCache, inserter, pathString, entry, oldContent);
+            if (contentLength >= 0) {
+                hasChanges = true;
+                numBytes += contentLength;
+            }
-            final long contentLength = applyPathEdit(dirCache, inserter, convertedPath, value, null);
-            if (contentLength > 0) {
-                hasChanges = true;
-            }
-            numBytes += contentLength;
+            final long contentLength = applyPathEdit(dirCache, inserter, convertedPath, value, null);
+            if (contentLength >= 0) {
+                hasChanges = true;
+                numBytes += contentLength;
+            }
-        return 0;
+        return -1; // No change
🤖 Fix all issues with AI agents
In
`@server-mirror-git/src/main/java/com/linecorp/centraldogma/server/internal/mirror/AbstractGitMirror.java`:
- Around line 262-268: The current logic leaves previousCommitId null for
orphan/root HEADs, causing downstream change-detection to ignore remote manual
pushes; modify the block in AbstractGitMirror around RevCommit headCommit so
that if headCommit.getParentCount() == 0 you set previousCommitId =
headCommit.getId() (i.e., treat the HEAD commit as the baseline for comparison)
instead of leaving it null; ensure you still call revWalk.parseHeaders(...)
appropriately for non-orphan parents and only use headCommit.getId() as the
fallback baseline used by the change-detection logic that reads
previousCommitId.
🧹 Nitpick comments (2)
server-mirror-git/src/main/java/com/linecorp/centraldogma/server/internal/mirror/AbstractGitMirror.java (2)

903-905: Consider using remoteUri() in the exception message for full context.
This keeps error reporting consistent with other updated messages.


313-318: Consolidate config hash computation with inherited hashString() method.
Line 313 duplicates the hashing logic already defined in the parent class, which also provides field-level caching for efficiency.

♻️ Suggested change
-                final String configHash = Hashing.sha256().hashString(toString(), UTF_8).toString();
+                final String configHash = hashString();

@ikhoon ikhoon merged commit 7105683 into line:main Feb 3, 2026
9 of 12 checks passed
@ikhoon ikhoon deleted the fix-mirror-state branch February 3, 2026 08:26
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.

Mirror is not updated when only the path has changed

2 participants