Everything documented here behaves as shown: what each command does, what it refuses and why. To run all of it against disposable projects first, follow the hands-on tutorial.
Configuration
A project is the nearest directory above your working directory that contains
a .gitone.yml. Nested GitOne projects are not supported.
| File | Committed | Contains |
|---|---|---|
.gitone.yml | yes | the public ownership map: everything that is safe for anyone who clones a public repository to read |
.gitone.local.yml | never | private repository names, private paths and private remotes |
.gitone/ | never | the managed Git repositories, the project lock and recovery state |
gitone init adds .gitone/ and .gitone.local.yml to the project
.gitignore without touching your existing entries or comments. Both are
reserved paths: no repository can own, stage or commit them, and doctor
fails if one of them ever reaches an index or a commit.
Example
.gitone.yml - committed, and it explicitly assigns itself and .gitignore:
version: 1
default_branch: main
rules:
protected_paths:
- .env
- secrets/**
push:
require_clean_worktree: true
repositories:
website:
visibility: public
remote: git@github.com:example/website.git
paths:
- .gitignore
- .gitone.yml
- README.md
- src/**
.gitone.local.yml - never committed, so private names, paths and remotes
stay off every public remote:
version: 1
repositories:
notes:
visibility: private
push: disabled
remote: git@internal.example.com:ops/notes.git
paths:
- notes/**
Check it before doing anything else:
gitone repo validate
Project fields
| Field | Required | Meaning |
|---|---|---|
version | yes | 1, the initial and only supported schema. |
default_branch | yes | The branch init creates for every repository. |
rules.protected_paths | no | Hard-deny paths that must never be owned, tracked or published. |
rules.push.require_clean_worktree | no | When true, refuse a push with staged, unstaged or untracked changes. Defaults to false. |
Repository fields
| Field | Required | Meaning |
|---|---|---|
visibility | yes | public or private. Reported by status; it does not by itself grant or deny anything. |
push | no | allowed by default. disabled skips the repository during gitone push and refuses it as an explicit push target. |
paths | yes | At least one ownership pattern. |
remote | no | Shorthand for remotes.origin. Defining both with different URLs is an error. |
remotes | no | Named remotes. Only origin is used by fetch, pull and push. |
Repository names match [a-z][a-z0-9_-]*; all is reserved for command
targets. A repository without a remote works for every local command; only
pushing it fails.
default_branch and every key below remotes are checked with native Git’s
own git check-ref-format, so a configuration gitone repo validate accepts
is one the Git commands GitOne runs accept too. Such a name contains no space
and none of ~ ^ : ? * [ \, no .. and no @{, no leading, trailing or
doubled /, no leading . or - and no trailing . or .lock.
default_branch is additionally never HEAD. Names like bad branch,
-dash or feature.lock are rejected with CONFIG003 before any repository
is created.
Path grammar
| Pattern | Matches |
|---|---|
README.md, src/site.css | that one exact path |
src/** | every path below src/, at any depth |
*.md | every Markdown file in the project root, .hidden.md included |
src/*.min.*.css | every matching name directly in src/ |
docs/**/*.md | docs/README.md and every deeper Markdown file |
**/*.md | every Markdown file, including README.md in the root |
* matches zero or more characters except /, including a leading ., and
may appear several times in one segment. ** matches zero or more complete
path segments, has to be a complete segment of its own and may appear at most
once in a pattern. Everything else - ?, [abc], {a,b}, negation, regular
expressions and Git pathspec syntax - is rejected as invalid configuration.
Duplicate patterns are an error, including patterns that differ only by
case, inside one repository as well as across repositories. Overlapping
patterns are not: docs/** and **/*.md may live in the same repository and
still produce one owner, and two repositories may hold patterns that could
theoretically meet. There is no priority and no most-specific-match rule, so
the moment a concrete path matches two repositories the operation stops
with PATH002. A protected pattern may overlap ownership because its hard
deny wins explicitly.
Paths are relative to the project root, use /, must stay below the root and
are matched case-sensitively. Case-insensitive collisions are rejected as
well, because macOS and Linux disagree about them.
Every relevant file must match exactly one repository. Zero matches is
PATH001, more than one is PATH002. This includes ordinary project files:
.gitone.yml, .gitignore, LICENSE and README.md need explicit
ownership like everything else.
Only project .gitignore files make a path irrelevant; a global Git exclude
does not. An ignored directory tree is not walked at all. Already tracked
files always need ownership, even if they are ignored later.
protected_paths uses the same pattern grammar, but is a hard deny rather
than ownership. A protected path that is visible, tracked or present in
outgoing history produces PATH003 or PUSH001, even when an ownership
pattern also matches it. Keep local secret files ignored as usual; the rule is
the extra guard that stops them if they become relevant or enter history.
.gitone.yml and .gitignore cannot be protected because every project must
own them.
Merge rules
.gitone.local.yml merges into .gitone.yml by repository name:
- a repository only the local file names is added
- project
default_branchand repositoryvisibilityare replaced - local
protected_pathsextend the committed list; they never remove entries - the push rules can only be tightened: a local
push: disabledand a localrules.push.require_clean_worktree: truetake effect, a localallowedorfalsenever weakens the committed value remotesmerge per remote namepathsreplaces the whole public list, it does not extend itnulldeletes nothing
Unknown fields, duplicate YAML keys, several YAML documents in one file and
any version other than 1 are errors. default_branch belongs only at
project level. New setup runs write this version 1 format.
Interactive setup
gitone setup is the guided entry point. It asks for the configuration a
project still needs and then runs the initialization or migration the current
directory requires:
gitone setup
It is interactive only: it takes no flags, and without a terminal on standard
input it fails with SETUP001 and changes nothing. In a project that has no
configuration yet, the first question is the setup mode.
On a normal terminal, choices and confirmations use the arrow keys and Enter,
and the owned paths are chosen in a navigable browser.
Text fields keep their current value when Enter is pressed, and setup shows a
live spinner while it initializes, migrates or installs the optional VS Code
extension. Without a suitable terminal, or with NO_COLOR, setup asks the same
questions as stable lines that name their choices and defaults, including the
numbered owned-path list.
Setup modes
A project without configuration is asked first how it is set up:
| Mode | What it does |
|---|---|
Wizard | The guided questions below, followed by the initialization or migration the directory needs. It is preselected, so Enter chooses it on both terminal styles. |
Template | Writes .gitone.yml and .gitone.local.yml as commented English templates you edit yourself, and stops. |
A directory that already has a .gitone.yml is never asked: setup validates
the existing configuration and continues with it unchanged.
Template mode
Template mode is the two-step alternative for anyone who wants to read and
edit every supported setting instead of answering questions. It asks about the
agent instructions first, then names what it
would write and asks one question with default no:
.gitone.ymlis created.gitone.local.ymlis created.gitignoreis created or updated with the GitOne entriesAGENTS.mdis created or updated with the instructions, if you asked for it- no repository is initialized or migrated
Cancelling writes nothing. A confirmed run writes both files under the same
rules as the wizard: the ignore entries exist before .gitone.local.yml does,
.gitone.local.yml gets owner-only permissions, .gitone.yml gets the
ordinary 0644, and an existing configuration file is never replaced.
.gitone.yml holds an active version, default_branch and exactly one
repository named <repository-name> with its visibility and the two project
files it has to own. That name is invalid on purpose, so an untouched template
can never initialize a plausible example repository - gitone repo validate
refuses it until you replace it. Every optional project and repository field
is there as a commented example: protected paths, the clean-worktree push
rule, push, remote, named remotes, exact paths and wildcard patterns.
.gitone.local.yml holds an active version: 1, one fully commented private
repository and the merge rules in comments. If you asked for the agent
instructions, the placeholder repository also owns AGENTS.md, with a comment
saying that instructions which must stay private belong to a repository in
.gitone.local.yml instead.
Template mode inventories none of your project files. It only checks the root
AGENTS.md for the optional instruction step, creates no .gitone/, and runs
neither init nor migrate - not even when the directory already contains a
root .git/. It ends with the two commands to run once the files are edited:
gitone repo validate
gitone setup
That second run takes the ordinary existing-configuration path and initializes or migrates the project.
The safety preflight
Before the wizard asks for the default branch or a repository, it inventories the project once and refuses the problems that no answer could fix, so they never cost you a full run:
- a
PATH003path the inventory decides on its own: an unsupported symbolic link, a link chain, a link target outside the project, a path name GitOne cannot manage, or a nested Git repository - state below
.gitone/that no operation explains: an unknown entry, or repository state left over from a configuration that no longer exists
Nothing is written and no default-branch or repository question is asked. Ownership-dependent safety is not guessed here - it needs the finished configuration and is checked before the write.
Template mode has no preflight: it inventories nothing, exactly as described above.
What setup asks
The wizard asks once for the shared default branch and then for one or more repositories:
| Question | Meaning |
|---|---|
| Default branch | The branch every repository uses. Enter accepts main. A name native Git rejects, such as bad branch or -dash, is asked again. |
| Repository name | Matches [a-z][a-z0-9_-]*, is not all and is used only once. |
| Visibility | Arrow-key choice between public and private; public starts selected. Enter accepts public. |
| Owned paths | The paths this project has, browsed per directory on a normal terminal and listed flat on a plain one, plus Custom pattern...; see below. At least one is required. |
| Origin remote URL | Optional. Empty keeps the repository local. The remote is always named origin. |
| Store in | shared writes the repository to .gitone.yml, local writes it to .gitone.local.yml. |
| Owner | Which repository owns .gitone.yml and .gitignore. Only asked when the answer is not already clear. |
| Agent instructions | Whether the managed block is added to AGENTS.md. Default no; a stale marked block is offered as an update with default yes. Asked last, before the summary. |
| VS Code extension | After project setup succeeds, offered with default no only when code is available and devidevio.gitone is not installed. |
Setup reads your files for one purpose only: to offer the owned paths below.
Repositories, names, visibility, remotes and the file a repository is stored in
are never derived from the working tree, and no ownership is ever assigned for
you. Project setup performs no network operation - a remote URL is only stored.
If you separately answer yes to the final extension question, VS Code’s code
CLI may contact the Marketplace.
Each repository is written completely to the file you selected, so the two
files never override each other. .gitone.local.yml is created only when you
chose it, with owner-only permissions, and the .gitignore entries exist
before it does, so private configuration is never visible to Git.
Choosing the owned paths
The owned-path question is the one place setup looks at your files. It
inventories the project exactly as the ownership validation does, so ignored
paths, GitOne’s own state and the .gitone.yml and .gitignore files setup
assigns itself never appear. What is left is offered as:
- every relevant file as its exact path;
- every relevant directory as
directory/**, which covers everything inside it, accepted links included; - every accepted symbolic link as its exact path, a link to a directory included: setup offers the link itself and never looks through it.
Hidden entries such as .github/ are offered like any other. Both terminal
styles produce the same patterns for the same answer, but they ask
differently.
A normal terminal browses the inventory one directory at a time. It shows the immediate entries of the current directory, directories before files and both alphabetically, with the breadcrumb of where you are and the number of patterns you have selected:
┃ Owned paths
┃ /docs/ 2 selected
┃ > [~] Select this directory: docs/** 2 selected, 1 unavailable
┃ [~] docs/nested/** 1 selected, 1 unavailable
┃ [x] docs/guide.md
┃ [ ] docs/shared
┃ Custom patterns
┃ [x] docs/**/*.md
┃ Custom pattern...
↑ up • ↓ down • →/l open • ←/h parent • x toggle • / filter • ctrl+a select all • enter submit
| Key | What it does |
|---|---|
↑/↓, k/j | move the cursor |
→/l | open the directory under the cursor |
←/h | return to the parent directory; nothing at the project root |
Space/x | select or release the path under the cursor |
Enter | submit the complete answer |
Shift+Tab | go back to the previous question |
/ | filter the current directory only; Esc sets it, Esc again clears it |
Ctrl+A | select the whole current directory, or every selectable entry of it |
Every non-root directory offers Select this directory: docs/** as its first
row, and the same pattern can be selected from the parent view. The project
root has no such row: setup never offers or infers a ** catch-all.
The checkboxes read [x] for a path this repository owns, [~] for a
directory with owned descendants, [!] for a directory that contains a path
another repository already owns, and [ ] for a free path. A directory row
names how many of its concrete paths are selected and how many are
unavailable.
The answer stays canonical. Selecting a whole directory removes the exact-file
and descendant-directory patterns setup generated below it; inside a selected
directory every entry is shown dimmed as covered by docs/** and cannot be
changed until that first row releases the pattern. Patterns you typed yourself
are never removed by a directory selection, however redundant they become; a
path one of them owns is shown as covered by a custom pattern.
A filter narrows the current directory only. While it is applied the
Select this directory row and the custom-pattern section are hidden, so
Ctrl+A can never reach a pattern wider than the visible matches, and
changing directory clears it.
A plain terminal keeps the stable numbered list of the top-level patterns, answered with comma-separated numbers. It has no navigation, no filter and no partial state; anything deeper is entered as a custom pattern. The same answer still produces the same ownership patterns in both styles.
Both styles end with Custom pattern..., which continues into text input for
one or more patterns of your own, ended by an empty field. In
the browser the input opens immediately and returns you to the same directory
with the cursor where it was. Patterns you typed stay listed in a
Custom patterns section, where you can release them again. Every repository
needs at least one offered or one custom pattern. A project with no relevant
file is offered only Custom pattern...; setup never falls back to ** or
another catch-all.
Paths an earlier repository already owns stay visible instead of hiding the
directory that contains them. They are shown in the error color with the owner
named, such as owned by backend, their directories stay open to navigation
and count them, and a whole-directory pattern that would overlap that
ownership cannot be selected.
Each answer is checked while you give it:
| Situation | What happens |
|---|---|
| an offered path an earlier repository already owns | shown as unavailable in the browser, not offered in the numbered list |
| a custom pattern another repository already configured | rejected, enter another |
| a custom pattern overlapping another repository on a real file | rejected, enter another |
| a custom pattern that matches nothing today | warned; keep it for later files or edit it |
| two patterns of the same repository overlapping | allowed, exactly as in the configuration file |
Correcting the owned paths
Before anything is written, setup validates the complete generated
configuration against the working tree. When the only problems are unassigned
(PATH001) or ambiguous (PATH002) paths, setup prints them and corrects them
without asking anything else again.
Unassigned paths are assigned directly. Setup asks, once per path and in alphabetical path order, which existing repository owns it and lists the repositories in the order you entered them; with a single repository it assigns every path to that one without asking. Each answer adds exactly the reported path - never a parent directory, glob or catch-all derived from it. Setup then validates the complete configuration again.
Ambiguous paths reopen the owned-path browser of the repository you name, with its current patterns selected. A selected pattern that now overlaps another repository stays selected so you can release it, is marked as conflicting, and blocks the answer until it is gone. When one round reports both kinds, the unassigned paths are assigned first and only the remaining ambiguity reaches the browser.
The default branch and every name, visibility, remote, storage location and other repository’s ownership stay exactly as entered, and the project files stay assigned exactly once, however often you correct.
A project file claimed by a repository stored only in .gitone.local.yml
reopens exactly two questions of that repository: its owned paths, with the
current patterns selected, and its Store in answer, with the current one as
the default. Its name, visibility and remote, every other repository and the
default branch stay unchanged. You end the correction either by releasing the
claim, so a repository in .gitone.yml owns the project files, or by storing
this repository in .gitone.yml. Setup never moves a repository or drops a
pattern for you, so the question repeats until one of the two is true or you
cancel.
Every other problem ends setup with its concrete error and changes nothing: a
PATH003 issue that ownership decides, such as a protected path or a link
whose target belongs to another repository. None of those can be fixed by
choosing different owned paths.
The project files
.gitone.yml and .gitignore need explicit ownership like every other
project file, but setup creates them itself, so you do not enter them in the
owned paths. After the repository questions, setup assigns both:
| Situation | What happens |
|---|---|
you entered neither file, one repository is in .gitone.yml | that repository owns both |
you entered neither file, several are in .gitone.yml | setup asks which one owns both; there is no default |
you entered neither file, none is in .gitone.yml | setup asks which repository is moved from .gitone.local.yml to .gitone.yml and owns both; every other answer you gave is kept |
you entered one of the files for a repository in .gitone.yml | that assignment is kept and the other file is added to it |
you entered both files, for one or two repositories in .gitone.yml | both assignments are kept unchanged |
Only a repository in .gitone.yml can own them, because a clone that has only
the committed file must still find them. A repository in .gitone.local.yml
is therefore never used silently, and claiming a project file for one - for
example with a broad pattern such as ** - reopens that repository’s paths
and storage before anything is written; see
correcting the owned paths.
The final summary shows the resulting ownership as part of the printed
configuration, together with the note that .gitone.yml is created and
.gitignore is created or updated.
What setup does
Before anything is written, setup prints the complete generated configuration and the planned action and asks one question. That single confirmation also authorizes a migration, so no second question follows.
The action comes from the state of the current directory:
| State | Action |
|---|---|
| no configuration | setup asks for the mode: Wizard asks the questions and the project state below decides, Template writes the files and stops |
configuration and a root .git/ | migrate, including its warning about a modified working tree |
configuration, no root .git/, missing repositories | init |
| configuration and healthy repositories | reported as done; nothing changes |
An existing .gitone.yml or .gitone.local.yml is never edited or
overwritten. Change configuration by editing the file yourself.
Setup refuses, with nothing changed:
- a directory inside another GitOne project or inside another Git repository, because nested projects are not supported
- an invalid existing configuration
- an interrupted migration, which
gitone migrateresolves - repository state below
.gitone/that neitherinitnormigrateexplains - a working tree the preflight already rejects, before the wizard asks anything
Cancellation and retry
Choosing no, pressing Ctrl+C, or closing the input cancels setup and writes
nothing, including a cancelled correction. An invalid single answer is asked
again. A problem the preflight can already see refuses
the run before the first question. A path problem that only the complete
configuration reveals returns you to the
owned-path correction before anything is
written; anything else refuses the run unchanged.
If the configuration is valid but the following init or migrate fails, the
written configuration is kept and the failure is reported. Fix the reported
problem and run gitone setup again: it then uses the existing configuration
unchanged and only retries the action.
The extension question comes only after project setup has completed. Setup
never passes --force. If the optional installation fails, the completed
project stays in place and the error starts with VSCODE001.
Terminal presentation
Human output uses the terminal’s basic color palette close to native Git:
staged changes and successful checks are green, unstaged and untracked changes
and errors are red, warnings are yellow, branches are cyan, and secondary
metadata is subdued. Slow actions show an updating spinner: init, migrate,
clone, fetch, pull, push, branch <name>, switch and the VS Code
installation.
Color and live rendering are automatic. Redirected output, TERM=dumb and
NO_COLOR stay plain; NO_COLOR also makes setup use its stable line-oriented
questions. status --porcelain, status --json, vscode info --json and file
content written by show never contain presentation codes. diff and log
color their output like Git does on a terminal and print it plain otherwise;
the pager and any external diff program stay disabled either way.
Everything GitOne prints for a person passes one shared output guard first.
Only presentation generated by GitOne gets through; every control sequence in
repository data is dropped. A commit subject, patch, path name or Git error
detail can therefore neither change presentation nor move the cursor, retitle
the window, write the clipboard or overwrite a line GitOne already printed.
Printable text and normal Unicode names stay exactly as they are. File content
written by show is the one exception: editors diff against the stored bytes,
so those bytes are passed through unchanged.
Commands
GitOne uses an explicit allowlist. An unknown command or flag is rejected with
CLI001; nothing is forwarded to Git. The table matches gitone --help.
| Command | Purpose |
|---|---|
gitone setup | Ask for the missing configuration, then initialize or migrate the project. |
gitone clone <url> [directory] | Create a project from a repository that commits .gitone.yml. |
gitone init | Create the managed repositories and the ignore entries. |
gitone migrate [--yes] | Convert a root .git/ into managed repositories. |
gitone backup list | List the retained migration backups. |
gitone backup git [--backup <id>] -- <log|show|diff|status> [arguments...] | Read a migration backup with native Git. |
gitone backup restore [--backup <id>] [--yes] | Copy a migration backup back to the root .git/. |
gitone repo validate | Check the merged configuration. |
gitone repo list | List the repositories, visibility, branch, push policy and remotes. |
gitone status [--porcelain | --json] | Report the whole project, also when called in a subdirectory. |
gitone add [-A | -u | <path>...] | Stage changes in every affected repository. |
gitone unstage [-A | <path>...] | Unstage changes without changing working-tree files. |
gitone restore [--staged] <path>... | Discard working-tree changes, or unstage with the Git spelling. |
gitone commit <-m <message> | -F -> [<path>...] | Commit staged changes, or only the selected files. |
gitone diff [all | <repository>] [--staged] | Show the changes of every repository as one native Git patch view. |
gitone log [all | <repository>] [-n <count>] | Show the recent commits of every repository. |
gitone branch [<name>] | List the local branches, or create one in every repository. |
gitone switch <branch> | Move every repository to the same existing local branch. |
gitone fetch [all | <repository>] | Update the origin remote-tracking refs of every repository. |
gitone pull [all | <repository>] | Fast-forward every repository to its origin branch. |
gitone push [all | <repository>] [--yes] | Push every repository that has outgoing commits. |
gitone show --repository <name> --source <head|index> -- <path> | Write exact file content from a repository source. |
gitone vscode info --json | Report the VS Code integration protocol and GitOne version. |
gitone vscode install [--vsix <path>] [--force] | Install the Marketplace extension or an explicit local VSIX through code. |
gitone recover | Finish an interrupted add, unstage, restore, commit, branch, switch or pull; report an interrupted push. |
gitone abort | Undo an interrupted add, unstage, restore, commit, branch, switch or pull; report an interrupted push. |
gitone doctor | Report project health without changing anything. |
gitone agents | Check the GitOne instructions in the project AGENTS.md. |
gitone agents update | Write the current GitOne instructions into the project AGENTS.md. |
| Option | Purpose |
|---|---|
gitone | Print the global help, exactly like gitone --help. |
gitone --help | Print the usage, every command and the global options. |
gitone <command> --help | Print the arguments and options that command accepts. |
gitone backup, gitone repo, gitone vscode | Print the help of that command group. |
gitone --version | Print the version of the binary. |
Commands exit 0 on success and 1 on failure. A valid show request whose
path does not exist in the selected source exits 2 with SHOW001, so editor
integrations can distinguish an absent version from a broken command.
Failures print a stable numeric code as the first token.
Commands that need an initialized project
status, add, unstage, restore, commit, show, diff, log,
branch, switch, fetch, pull, push, recover and abort work on managed
repository state. They check that the project is usable before
doing anything, and a project that is not usable is reported with the command
that makes it usable:
| Project state | Failure | Next command |
|---|---|---|
No .gitone.yml in this directory or any parent | CONFIG001 configuration missing | gitone setup |
| Valid configuration, no repository initialized | REPO001 no managed repository is initialized | gitone init |
The same, but a root .git/ exists | REPO001 no managed repository is initialized | gitone migrate |
The same, but root .git is a file or symlink | REPO001 no managed repository is initialized | gitone doctor |
| Some repositories missing, or metadata that is not a GitOne repository | one REPO001 line per repository | gitone doctor |
Nothing is created, staged, committed or pushed by a refused command, and
existing repository metadata is never reinitialized over: partial or invalid
metadata and unsupported root .git forms are cases for gitone doctor, not
for gitone init or gitone migrate.
gitone --help, gitone <command> --help, gitone --version, gitone setup,
gitone init, gitone migrate, gitone backup list, gitone backup git,
gitone backup restore, gitone repo validate, gitone repo list,
gitone doctor, gitone vscode info and gitone vscode install deliberately
work before initialization and are not affected by this check. The backup
commands stay usable in exactly the states that need them, including after a
restored root .git/ makes the project unusable for everything else.
Notes on the arguments:
add,unstageandrestorepath arguments are resolved relative to your current directory, and.covers the current subtree. Absolute paths are rejected. Apart from., each argument must name one managed file; there are no Git pathspecs, globs or--separators.restoreneeds at least one path and has no-Aform. Without--stagedit overwrites the selected working-tree files with the version their owning repository has in the index, which discards the changes in them.--stageddoes exactly whatunstagedoes.--source, revisions, patch mode and--ours/--theirsare not accepted.commit -F -reads the complete commit message from standard input. No other file name is accepted.commitaccepts explicit paths before or behind the message, as ingitone commit src/site.css -m "Adjust the layout"orgitone commit -m "Adjust the layout" src/site.css. Each one is resolved relative to your current directory and must name one managed file Git already knows;., directories, globs, Git pathspecs,--and absolute paths are rejected withCLI001orPATH003before anything changes. A second-mor-Fis rejected the same way.difftakes no target,all, or one repository name, each optionally with--staged. It prints one heading per repository followed by the native Git patch, in configuration order; a repository without matching changes is left out, and no change at all prints nothing and exits0.--stagedcompares the index againstHEAD, which also reports additions in a repository without a commit yet. Untracked files never appear in a diff;gitone statusreports them. Paths, revisions,--stat,--patchand every other Git diff option are rejected withCLI001, as is an unknown repository name.logtakes no target,all, or one repository name, each optionally with-n <count>.countmust be a whole number from1to1000and defaults to20. Revisions, paths,--graph,--pretty,--patchand every other Git log option are rejected withCLI001, as are an unknown repository name and an out-of-range or non-numericcount.branchtakes no argument to list, or exactly one branch name to create that branch in every repository.-d,-D,-m,-c,-f,--list,--all,-r, a start point and a second name are rejected withCLI001; an invalid branch name is rejected withBRANCH001.fetchtakes no target,all, or one repository name. Remotes, refspecs,--prune,--force, tags and shallow options are rejected withCLI001, as is an unknown repository name.pulltakes the same target asfetch.--rebase,--ff,--no-ff,--ff-only, remotes, refspecs and every other Git pull option are rejected withCLI001; a pull is always fast-forward-only.pushtakes no target,all, or one repository name, each optionally with--yes. Remotes, refspecs,--forceand tags are not accepted.backup gitneeds the separating--and accepts onlylog,show,diffandstatusbehind it. Everything after that command is passed to Git unchanged; every other Git command and every Git option before the command are rejected withCLI001. The exit code is the one Git returned.backup gitandbackup restoreselect the only backup a project has by themselves. As soon as a second directory exists below.gitone/migration-backup/, including an invalid one, the exact--backup <id>is required; a prefix, a friendly name and “the latest one” are not accepted, and an ID containing a path separator or naming a symbolic link is rejected withMIG001.--helpand--versionneed no project and change nothing.--helpis accepted forgitone, forrepoand for every command in the table above, and only as the last argument.-h,gitone helpand any extra argument after--helpare rejected withCLI001.gitonewithout any argument prints the global help and exits0, and a command group called without a subcommand prints its group help and exits0. Nothing is read or changed, so both work in any directory.- Input rejected by the allowlist keeps exit code
1and theCLI001 unsupported commandprefix, followed by one hint line: input that names a known command points togitone <command> --help, everything else togitone --help. The hint never relaxes the allowlist and is printed on standard error only.
The normal workflow
The following commands create a disposable project and two local bare remotes, so the complete workflow needs no network access:
fixture="$(mktemp -d)"
mkdir -p "$fixture/remotes" "$fixture/project/src" "$fixture/project/notes"
git init --bare "$fixture/remotes/website.git"
git init --bare "$fixture/remotes/notes.git"
cd "$fixture/project"
cat > .gitone.yml <<'YAML'
version: 1
default_branch: main
repositories:
website:
visibility: public
remote: ../remotes/website.git
paths:
- .gitignore
- .gitone.yml
- README.md
- src/**
YAML
cat > .gitone.local.yml <<'YAML'
version: 1
repositories:
notes:
visibility: private
remote: ../remotes/notes.git
paths:
- notes/**
YAML
printf '# Example\n' > README.md
printf 'body {}\n' > src/site.css
printf '# Plan\n' > notes/plan.md
export GIT_AUTHOR_NAME=GitOne
export GIT_AUTHOR_EMAIL=gitone@example.invalid
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
git init --bare is used only to create the two test remotes. Inside the
project, use gitone for every operation:
gitone repo validate
gitone init
GitOne initialized
✓ notes (main)
✓ website (main)
Nothing was staged or committed.
init requires a directory that already has a valid configuration and no root
.git/. An existing root repository is rejected with the hint to run
gitone migrate instead.
gitone status
NOTES
visibility: private
untracked: notes/plan.md
WEBSITE
visibility: public
untracked: .gitignore
untracked: .gitone.yml
untracked: README.md
untracked: src/site.css
BRANCHES
notes: main (no remote-tracking branch)
website: main (no remote-tracking branch)
status always covers the whole project, also when you run it in a
subdirectory. Ordinary changes exit 0. Anything unsafe is listed under
ISSUES and exits 1:
ISSUES
PATH001 secrets.txt: path is not assigned to any repository
gitone add -A
Staged 5 paths
notes:
added notes/plan.md
website:
added .gitignore
added .gitone.yml
added README.md
added src/site.css
Every mutating command validates the complete managed working tree first, even
when it only touches one path. As long as secrets.txt above is unassigned,
add, commit and push all refuse to run.
gitone commit -m "Add site and notes"
notes:
commit ca535f0bcfc7570850c20d5a5ca5b1634a0bcf3f
website:
commit 0938d2a7ec12ba398dd1e61691d9b982e25388ea
GitOne-Group: 6bfc8400
A commit that spans two or more repositories gives every created commit one
shared GitOne-Group trailer, so the parts can be found again later. A commit
in a single repository is an ordinary native commit with no trailer. commit
never pushes.
Committing selected files
gitone commit src/site.css notes/plan.md -m "Adjust the layout"
With explicit paths this is native git commit <path>...: the current
working-tree version of exactly those files is committed, even when a
different version was staged. Every unselected staged change stays staged, and
a repository without a selected change gets no commit at all - so the same
selection may produce one commit, several grouped commits, or Nothing to commit.
A file Git does not know yet cannot be selected; stage it once with
gitone add first. A rename or a deletion follows the same rule as in native
Git: only the paths you name are committed, and GitOne never adds the other
side of a rename to your selection.
The whole result is checked before the first commit exists. The tree every
participating repository would create has to satisfy the ownership, protected
path and safe-link rules, so a selection that would commit a link without its
target is refused with PATH003 and nothing is changed.
gitone push
Checking repositories...
NOTES ✓ reachable
WEBSITE ✓ reachable
Validating paths...
✓ no unassigned files
✓ no ambiguous paths
✓ every published path belongs to its repository
Ready to push:
NOTES
remote: origin
branch: main
commits: 1
WEBSITE
remote: origin
branch: main
commits: 1
Continue? [y/N]
The default answer is no. Answering anything but y/yes prints:
Push aborted.
No repositories were modified.
When standard input is not a terminal - a script, a CI job, an agent - there
is nobody to ask, so the push fails immediately unless you pass --yes:
PUSH001 a push without an interactive terminal requires --yes
No repositories were modified.
After a confirmed push:
notes:
pushed ca535f0 to origin/main
website:
pushed 0938d2a to origin/main
Selective push
gitone push website --yes
Only website is published, but the whole working tree is still validated
first: local path safety does not depend on which repository you publish.
Repositories without outgoing commits never participate, and their remotes are
not contacted at all - gitone push on an up-to-date project prints
Nothing to push.
A repository with push: disabled is skipped by an all-repository push and is
listed before the result. Naming it explicitly fails with PUSH001 before any
remote is contacted. When rules.push.require_clean_worktree is true, a push
with any staged, unstaged or untracked change also fails with PUSH001. By
default a dirty but otherwise safe working tree does not block a push.
Undoing changes
unstage takes paths out of the index and never touches a file:
gitone unstage src/site.css
Unstaged 1 path
website:
modified src/site.css
restore is the Git spelling for both directions. gitone restore --staged <path>... runs exactly the unstage above. Without --staged it replaces the
selected working-tree files with the version their owning repository has in the
index:
gitone restore src/site.css
Restored 1 path
website:
modified src/site.css
This throws the changes in those files away. There is no undo once the command finished; stage or commit anything you want to keep first. A file deleted from the working tree is written back, an untracked file is never restored, and no directory is ever removed.
Paths are resolved like add paths: relative to your current directory, .
for the current subtree, no absolute paths and no globs. Files belonging to
several repositories are restored in one operation. Every index version is
checked out below .gitone/recovery/ first, with the file mode the index
records, and the real files are replaced only afterwards, so a failure never
leaves half a file behind. If a replacement fails midway, gitone recover
finishes the remaining files and gitone abort puts back the versions the
restore started from.
Machine-readable status
Both formats carry a schema version that changes when a field changes
meaning or disappears. Both are deterministic: repositories are sorted by
name, changes by repository, then state (staged, unstaged, untracked),
then path.
gitone status --porcelain prints one tab-separated record per line. Managed
paths cannot contain tabs, so spaces in filenames stay unambiguous.
version 1
repository notes private main ? ?
repository website public main ? ?
change notes staged added notes/plan.md
change website staged added .gitignore
| Record | Fields |
|---|---|
version | schema version |
repository | name, visibility, branch, ahead, behind |
change | repository, state, type, path, previous path (renames and copies) |
issue | code, path, detail |
gitone status --json:
{
"version": 1,
"repositories": [
{
"name": "notes",
"visibility": "private",
"branch": "main",
"ahead": null,
"behind": null
}
],
"changes": [],
"issues": []
}
Ahead and behind counts come from local remote-tracking refs only, so they
exist after the first successful push, fetch or pull. Before that the
counts are reported as unknown - ? in porcelain, null
in JSON, (no remote-tracking branch) in the human output - never as 0.
A detached HEAD is reported as branch (detached) and blocks commit and push.
Editor integration contract
The VS Code extension uses only public GitOne commands. It does not inspect
.gitone/ and does not invoke Git directly.
Check protocol compatibility without requiring a project:
gitone vscode info --json
{"protocol":1,"gitone_version":"devel"}
Release binaries report their stamped version instead of devel.
Install a VSIX downloaded from the matching GitOne release without requiring a project:
gitone vscode install --vsix ./gitone_<tag>_vscode.vsix
During local extension development, replacement is always explicit:
gitone vscode install --vsix ./editors/vscode/gitone-<version>.vsix --force
Once devidevio.gitone is published in the Marketplace, install or update it
with:
gitone vscode install
All forms delegate to the official code command on PATH. Reload the VS
Code window after installation. --force is accepted only on this explicit
command and is never added by gitone setup.
Read the exact bytes needed for a native editor diff:
gitone show --repository website --source head -- src/site.css
gitone show --repository website --source index -- src/site.css
head reads the current branch commit; index reads the staged version.
The path must belong to the named repository. Missing content exits 2 with
SHOW001; unsafe input and Git failures exit 1.
Cloning a project
gitone clone git@github.com:example/website.git
gitone clone git@github.com:example/website.git ~/projects/website
clone is the one command that creates a project from a remote. It works
outside any GitOne project, and the destination must not exist yet. Without a
directory argument the repository name of the URL is used, exactly as native
git clone would. A relative local URL is resolved from the final project
directory, because the configured origin keeps using it from there.
Everything is built in a temporary directory next to the destination and only published once it validates. Authentication, configuration, checkout and validation failures therefore leave no destination behind and never change an existing path.
What a clone does, in order:
- clones the bootstrap URL through native Git, without a checkout
- reads the committed
.gitone.ymlfrom the default branch - requires exactly one repository to own
.gitone.yml, and that repository’s configuredoriginto be exactly the cloned URL - adopts the cloned repository, with its complete history, as that managed repository
- initializes every other repository of the committed configuration and
fetches the ones that have an
origin - refuses every incoming tree that contains a path its repository does not own, before the first file is written
- checks out each default branch, sets its
originupstream, writes the GitOne ignore entries and runs the same repository, path and configuration checks asgitone doctor
A repository without a configured origin is initialized but stays unborn and
is reported with its next steps instead of being guessed or omitted. A
configured origin without the default branch is refused.
What a clone cannot discover
.gitone.local.yml is never committed, so a fresh clone cannot know about
repositories that exist only there. They are not cloned automatically. Add
them to your own .gitone.local.yml afterwards and run gitone pull <name>.
--branch, --depth, partial clone, sparse checkout and submodules are not
accepted. An existing or non-empty destination is refused rather than filled.
Migrating an existing repository
A directory that is already one ordinary Git repository becomes a GitOne project with:
gitone migrate
Migration converts the project layout, not the history:
- Your files are not touched. Only the idempotent
.gitignoreentries are added, exactly asinitadds them. - The managed repositories are created empty. They have no commits and no staged files and use only the branch and remotes of your configuration.
- The complete old
.git/is copied to a timestamped directory below.gitone/migration-backup/before anything changes, and that copy is verified as a usable Git repository first. - Only then is the root
.git/removed. The backup stays forever; GitOne never moves or deletes it.
The command asks before it does anything, and warns more explicitly when the
working tree has staged, unstaged or untracked changes. --yes is the
non-interactive confirmation; without a terminal and without --yes the
command fails without changing anything.
Migration is refused, with nothing changed, when the project is not a plain
root repository: a .git file (linked worktree or submodule checkout),
linked worktrees, submodules, a bare repository, a nested repository, an
already migrated project, an invalid configuration or an unsafe path.
Migration and the restoration below never contact a remote.
What only lives in the backup afterwards
Everything the managed repositories do not get:
- every commit, branch and tag of the old repository
- stashes, reflogs, the index and the old staging state
- hooks, remotes and the Git configuration of the old repository
Listing, inspecting and restoring a backup
gitone backup is the command group for the retained backups. It needs a
configuration, but no usable managed repositories, so it also works in exactly
the states that make everything else fail.
gitone backup list
Migration backups below .gitone/migration-backup
ID CREATED (UTC) STATE
20260826T133234Z-223441957 2026-08-26 13:32:34 valid
Every direct directory below .gitone/migration-backup/ is listed newest
first with its full ID - the directory basename, the only durable
identifier a backup has - its UTC time and whether it still is a valid Git
repository. Symbolic links are not followed and not listed, invalid
directories stay visible, and a project without any backup prints
No migration backups found. and exits 0.
Read a backup with native Git:
gitone backup git -- log --all
gitone backup git --backup 20260826T133234Z-223441957 -- show HEAD
gitone backup git --backup 20260826T133234Z-223441957 -- status
Only log, show, diff and status are accepted, and only behind the
separating --; the arguments after them go to Git unchanged. The backup is
the Git directory and the project root is the working tree, so status
and diff compare the retained repository with your current files. Optional
locks are disabled, so no inspection writes anything into the backup. Git
output is streamed as Git produced it, without a GitOne heading around it, and
the exit code of Git is the exit code of the command.
Restore the old repository at any time - the managed repositories and the backup are independent, so nothing is lost by copying it back:
gitone backup restore --backup 20260826T133234Z-223441957
The command validates the backup, refuses any existing root .git path -
directory, file or symbolic link - and only then asks, defaulting to No.
--yes is the non-interactive confirmation; without a terminal and without
--yes it fails without changing anything. Only the complete .git/ is
restored: your files, the ignore entries, the configuration and the managed
repositories are never touched, and the backup itself is kept. The copy is
written into a temporary directory in the project root and validated there,
and only a complete, valid copy is published as .git/, so a cancelled or
failed restore leaves neither a partial root repository nor a temporary one.
While a root .git/ exists the ordinary GitOne commands are unavailable;
gitone backup list and gitone backup git still work, and another
gitone backup restore refuses the root repository it published.
Failure and interruption
A migration that fails after the backup was written is rolled back: the
repositories it created are removed, the root .git/ is copied back from the
backup, and the backup is kept.
While a migration runs it records its state in .gitone/migration/. If it is
interrupted, the next gitone migrate refuses to migrate again and offers the
restoration instead:
An interrupted migration was found.
Backup: /project/.gitone/migration-backup/20260825T101500Z-3f9c21
Restoring copies the backup back to /project/.git and removes the repositories the
migration created. The backup itself is kept.
Continue? [y/N]
The restoration copies; it never moves or consumes the backup. If a root
.git/ exists again, it is never overwritten - remove or move it yourself
first if you really want the backup back in place.
Locking, recovery and abort
Only one mutating operation runs at a time. The lock records the process and the command:
LOCK001 another GitOne operation is currently running: PID 4711, gitone commit
repo validate and doctor may still inspect a locked project. recover and
abort need the exclusive lock and can run only after the active process ends.
A stale lock is then reclaimed by the next operation - unless recovery state
must be resolved first:
REC001 an interrupted GitOne operation must be recovered first: run gitone recover or gitone abort
Staging and unstaging prepare every affected index as a temporary copy and
swap the real indexes in only after all Git work succeeded. Commit records the
starting branch position and index of every repository, and for a selective
commit the selected paths and the version each of those files has, before the
first commit is created, branch <name> records the commits its new refs are written at
before the first one exists, and switch <branch> records the starting
branches, commits and indexes before the first checkout. restore checks every
index version out beside the working tree and records the current version of
every selected file before the first one is replaced. They all write their
state to .gitone/recovery/.
gitone recover # finish an interrupted add, unstage, restore, commit, branch, switch or pull
gitone abort # undo an interrupted add, unstage, restore, commit, branch, switch or pull
With nothing to do, both print No interrupted GitOne operation found.
For an interrupted push, both commands only read the remote refs and report what was published. Neither command rolls a push back or silently repeats it.
abort refuses when a repository, a restored file or a selected file of an
interrupted selective commit changed outside GitOne since the interruption. It then prints the concrete manual steps - which commit
to inspect, which branch position to restore, which saved index or file version
to copy back - instead of overwriting work it did not make.
gitone doctor reports the lock and the recovery state without touching
either. It never reclaims a stale lock and never resolves recovery state.
Reading history
gitone log
gitone log website -n 5
gitone log prints one section per configured repository, in configuration
order, with the most recent commits of each. Every line comes from native Git
and contains the abbreviated commit ID, the author date, the ref decorations
and the subject:
PRIVATE
9c1f2ab 2026-08-24 (HEAD -> main) Add the authentication notes
PUBLIC
4e77d90 2026-08-25 (HEAD -> main, origin/main) Implement authentication
1b02c5e 2026-08-21 Initial commit
Each repository shows at most 20 commits; -n <count> lowers or raises that
up to 1000. A repository without a commit yet is labeled no commits yet
instead of failing the command, so one unborn repository never hides the
history of the others.
Every repository is logged on its own. A commit created by a multi-repository
gitone commit carries its GitOne-Group trailer, but log does not
reconstruct one shared logical history from it; the trailer is visible in the
full commit message through native Git.
log reads only: it never changes refs, indexes or working-tree files, and it
never starts a pager. Like every other command that works on repository state,
it checks that the project is initialized and refuses while another GitOne
operation or unresolved recovery state exists.
Branches
gitone branch
gitone branch feature/auth
gitone branch without an argument lists every local branch of the project
with the repositories holding it, followed by the current branch of every
repository:
Branches
BRANCH REPOSITORIES
feature/auth private, public
main private, public
release public
Current branch
private: main
public: main
release is missing from private.
Branch rows are sorted by name and the repositories are listed in
configuration order, so the output is deterministic. A branch that only part
of the project has, a repository whose HEAD is not on a branch, and
repositories that disagree about the current branch are stated below the list
instead of failing it: listing exists to show exactly that state.
A project whose repositories have no commit yet shows its current branch with
no repositories and the note main does not exist in any repository yet.
gitone branch <name> creates that branch in every configured repository,
at the commit each one currently has checked out:
Branch feature/auth created:
private ✓ feature/auth at 0c80747
public ✓ feature/auth at 00f3151
No repository switched branches. Every HEAD, index and working-tree file is unchanged.
Creation writes nothing but the new refs. Move the project onto the new
branch with gitone switch.
Every repository has to pass preflight before the first ref is written:
| Refusal | Failure |
|---|---|
| The name breaks native Git’s branch-name rules | BRANCH001 "<name>" is not a valid branch name |
| A repository has no commit on its current branch | BRANCH001 repository "<name>" has no commit on <branch> yet |
| A repository is not on a branch | BRANCH001 repository "<name>" is not on a branch |
| The repositories are on different branches | BRANCH001 the repositories are not on the same branch |
| A repository already has the branch | BRANCH001 repository "<name>" already has branch <branch> |
Every refusal reports all problems it found at once, leaves every repository
unchanged and closes with No branch, HEAD, index or working-tree file was changed. Because branch creation is a mutating command, the complete managed
working tree is validated too, so unassigned or ambiguous paths stop it as well.
The starting commits are recorded under the project lock before the first ref
exists, and every created ref gets an operation marker in its reflog. A failure
after that deletes only branches with that marker at the recorded commit and
reports the same unchanged-project message. Otherwise the recorded state stays
for gitone recover, which creates the remaining branches, and gitone abort,
which deletes the created ones.
Deleting, renaming, copying, force-resetting, upstreams, start points, remote branches and creating a branch in only one repository are not supported.
Switching branches
gitone switch feature/auth
gitone switch <branch> moves every configured repository to that branch
and updates the shared working tree with it:
Switch results:
private ✓ main → feature/auth
public ✓ main → feature/auth
The branch is never created. It has to exist in every repository already, so
the normal order is gitone branch feature/auth and then
gitone switch feature/auth. A repository that is already on the branch is
reported as already on feature/auth and stays untouched.
Every repository has to pass preflight before the first one moves:
| Refusal | Failure |
|---|---|
| A repository does not have the branch | SWITCH001 repository "<name>" has no branch <branch> |
| A repository is not on a branch | SWITCH001 repository "<name>" is not on a branch |
| A repository has staged or unstaged changes | SWITCH001 repository "<name>" has staged changes on <branch> |
| The target tree contains a path the repository does not own | SWITCH001 repository "<name>" <path> in <branch>: path is owned by <other> |
| The checkout would overwrite a file already in the working tree | SWITCH001 repository "<name>" cannot check out <branch> |
Every refusal reports all problems it found at once, leaves every repository
unchanged and closes with No branch, HEAD, index or working-tree file was changed. Because switching is a mutating command, the complete managed
working tree is validated too, so unassigned or ambiguous paths stop it as
well.
Local work is never carried across branches. GitOne does not stash, discard or merge: commit or undo your changes first. Untracked files stay where they are, and a checkout that would overwrite one is refused instead of overwriting it.
Repositories that disagree about their current branch are the one project
issue switch tolerates - putting them back on one branch is exactly what it
does. Every other command still refuses that state:
REPO001 the repositories are not on the same branch: private on feature, public on main: run gitone switch <branch>
The configured default_branch is only where a repository starts. After a
switch the project is on another branch, and gitone status and
gitone doctor accept that as long as all repositories agree.
The starting branches, commits and exact indexes are recorded under the
project lock before the first checkout, and every checkout is verified against
the target commit - native Git reports a checkout as done even when it could
not write every file. A failure after that returns the repositories already
switched to their starting branch and reports the same unchanged-project
message. Otherwise the recorded state stays for gitone recover, which
switches the remaining repositories, and gitone abort, which returns the
switched ones to their starting branch.
-c, -C, automatic branch creation, --detach, --force, --merge, start
points, remote branch guessing, paths and switching only one repository are
not supported.
Fetching
gitone fetch
gitone fetch website
gitone fetch updates the origin remote-tracking refs of every configured
repository, in configuration order, through native Git. Credentials, URL
rewrites, transport configuration and hooks behave exactly as they do for a
plain git fetch.
The only refs changed are below refs/remotes/origin/: branches, HEADs,
indexes and working-tree files stay byte-for-byte as they were. Native Git
still stores fetched objects and updates FETCH_HEAD. Ordinary staged and
unstaged changes are therefore allowed, but the complete managed working tree
is still validated before the first remote is contacted, exactly as for a push
- an unassigned or otherwise unsafe path stops the whole operation with
PATH001,PATH002orPATH003and no remote is contacted.
A repository without a configured origin is skipped by an all-repository
fetch and reported as such. Naming it as the target fails with REPO001
before any remote is contacted, as does an unknown repository name.
Each repository reports what really happened, decided by comparing its remote-tracking refs before and after the fetch:
Fetch results:
notes ✓ updated origin refs
website already up to date
scratch skipped, no configured origin remote
After a fetch, gitone status reports the new ahead/behind counts for every
repository whose branch tracks origin.
A fetch across several repositories is not atomic. Repositories are
fetched one after another, and a failure - authentication, transport, an
unreachable remote - names the affected repository and stops with FETCH001.
The remote-tracking refs that earlier repositories already received stay
updated and are never rolled back; the report above the error states exactly
which ones those are. Rerun gitone fetch to retry the rest.
fetch never merges, rebases, prunes or changes the working tree. Use
gitone pull to move the local branches.
Pulling
gitone pull
gitone pull website
gitone pull fetches the selected repositories and then fast-forwards only.
It never creates a merge commit and never rebases, stashes or discards local
work - there is no flag that changes this, because a repository that cannot be
fast-forwarded is a decision only a person can make.
Before the first local branch moves, every selected repository must pass the complete preflight:
- working tree - the whole managed working tree is valid, exactly as for a fetch, and a repository that would move has no staged or unstaged changes. Untracked files are allowed; native Git still refuses to overwrite one.
- upstream - the local branch tracks exactly
origin/<branch>, and that remote-tracking branch exists after the fetch. A missing or different upstream is refused withPULL001. - fast-forward - the local branch is an ancestor of
origin/<branch>. A diverged branch is refused; resolve it with Git yourself. - ownership - every path of the incoming tree belongs to that repository, so a remote commit can never smuggle another repository’s paths into the working tree.
Each repository is then reported with what really happened:
Pull results:
notes ✓ updated main to a1b2c3d
website already up to date
scratch skipped, no configured origin remote
A failed fetch or preflight prints the same complete report before returning the error; one failed fetch does not prevent the remaining fetches.
A branch that is already current or that is ahead of origin stays unchanged
and is reported as such; a local-only repository is skipped by gitone pull
and rejected as an explicit target with REPO001.
A pull is transactional for the local branches, not for the fetch. All
preflight decisions are made before the first branch, index or working-tree
file changes, so a refusal leaves the project exactly as it was. If a
fast-forward still fails after an earlier repository was advanced - an
untracked file in the way, a failing hook, a full disk - the repositories that
already moved are restored to the commits they started from, and the pull
fails with PULL001 and no branch changed.
If even that restoration is not safe, the recorded state stays behind:
gitone recover completes the remaining fast-forwards and gitone abort
restores the branches, indexes and files of every repository the pull
advanced. Both refuse a repository that changed outside GitOne since the
interruption and print the manual steps instead.
The remote-tracking refs a pull fetched always remain, also when the local
branch updates are rolled back. A later gitone pull fetches again before it
retries the local updates.
Push safety and partial failures
Before the first remote is modified, every participating repository must pass the complete preflight:
- policy - explicitly selected repositories allow push, and a configured clean-worktree rule has no staged, unstaged or untracked change to report
- destination -
originis configured, matches the configuration, and has exactly one fetch and one push URL that are the same URL - connectivity - the remote answers a read-only ref listing
- fast-forward - the remote branch is an ancestor of what would be pushed, and the commit it points at is known locally
- ownership - every path added, changed or deleted by every outgoing commit, and every path of the resulting published tree, belongs to that repository and is not protected. A branch the remote does not have yet is checked over its complete reachable history.
If any check fails, nothing is pushed and the message ends with
No repositories were modified.
The push itself sends the current branch to the branch of the same name on
origin, without force and without tags, and sets the upstream after the
first success. Other remotes, refspecs, deletions and force pushes are not
supported.
A push across several repositories is best effort, not atomic. Several remotes cannot be updated as one transaction. GitOne records what it intends to publish before the first remote changes and the result after each repository, so a failure in the middle is reported precisely instead of guessed:
Push results:
notes origin/main published ca535f0
website origin/main unchanged at 0938d2a
After a crash, gitone recover or gitone abort reads the recorded refs back
from the remotes and reports what is really there. Neither command rolls a
push back or silently repeats one; rerun gitone push to publish the rest.
Error codes
Every failure prints its code as the first token of the message.
| Code | Meaning |
|---|---|
CONFIG001 | configuration missing |
CONFIG002 | invalid YAML |
CONFIG003 | invalid configuration |
PATH001 | path assigned to no repository |
PATH002 | path assigned to more than one repository |
PATH003 | otherwise unsafe path |
REPO001 | repository invalid or unavailable |
GIT001 | native Git failure |
BRANCH001 | branch listing or creation refused or failed |
SWITCH001 | switch refused or failed for one or more repositories |
PUSH001 | push preflight failed |
FETCH001 | fetch failed for one or more repositories |
PULL001 | pull refused or failed for one or more repositories |
LOCK001 | another operation is running |
REC001 | recovery required |
CLI001 | unsupported command or flag |
CLONE001 | clone refused or failed |
MIG001 | migration refused, failed or interrupted |
SETUP001 | interactive setup refused |
AGENT001 | AGENTS.md missing, not current or not safely editable |
SHOW001 | requested path absent from HEAD or index |
VSCODE001 | VS Code CLI or extension installation failed |
CLI001 unsupported command is followed by one hint line naming the help page
that lists the accepted form: gitone <command> --help for a known command,
gitone --help otherwise.
CONFIG001 and REPO001 name the next command when a project is not ready:
see Commands that need an initialized project.
Security boundary
What GitOne enforces:
- a path is published only by the repository that owns it, checked against the outgoing commits and the resulting tree of every push
- a configured protected path is refused even when an ownership pattern also matches it
- an unassigned or ambiguous path stops every mutating operation
.gitone/and.gitone.local.ymlcan never be owned, staged or committed- an incoming tree carrying an unsafe symbolic-link entry is refused
before
clone,pullorswitchchanges a branch, an index or a working-tree file; a safe alias is materialized as the link it is - repository content cannot drive your terminal, see Terminal presentation
What GitOne does not do:
- it never inspects file contents. There is no secret scanning, no data
classification and no entropy check. A private key inside
src/is published like any other owned file unless its path is explicitly covered byrules.protected_paths. - it is not an access-control boundary. Everything sits in one directory that your shell, editor, backup tool and every other local program can read.
- it trusts the local environment: the Git binary, Git configuration, credential helpers, hooks and Git LFS filters all run as they normally do.
Unsupported inside managed paths, and reported as PATH003 rather than
silently handled: nested or bare Git repositories, non-regular files, ASCII
control characters in names, and an ignored .gitignore that would hide
other paths. Submodules are not supported at all. Normal Unicode filenames
are fine.
Symbolic links are supported only as a deliberate alias inside one repository, for example:
.claude/skills -> ../.agents/skills
Such a link is accepted when its stored target is relative and non-empty, resolves inside the project root, exists as a regular file or a real directory, is reached without passing through another link, and belongs to exactly the same repository as the link. A directory target must have at least one relevant path below it and every relevant descendant must have the link’s owner; an empty or fully ignored directory is refused. GitOne owns the link path itself and never traverses it to invent managed paths below the alias.
Everything else is PATH003, naming the link, the stored target and the
reason: an absolute target, a target outside the project, a target in another
repository, a dangling target, a link chain, a cycle, and any link that is or
reaches .gitone.yml, .gitone.local.yml, .gitignore, .gitone/, .git/
or a configured protected path. GitOne never opens such a target for reading
or writing.
Restore, recover and abort preserve a link as a link: the stored target is recorded and put back without ever following it. Add, unstage, restore, recovery, migration, pull and switch validate the state they would leave behind, so a selection that covers a link without its target is refused and nothing changes.
Instructions for AI agents
GitOne publishes one canonical instruction block. gitone agents update
writes it into the project AGENTS.md; you can also copy it into CLAUDE.md
or any equivalent yourself.
The managed block
Only <project-root>/AGENTS.md is managed. Nested instruction files,
CLAUDE.md and tool-specific equivalents are never searched for or edited.
The block lives between two fixed markers:
<!-- gitone:agents:start -->
...the block below...
<!-- gitone:agents:end -->
Complete markers define the text GitOne owns, so anything between them is replaced. Everything outside them is preserved byte for byte, an existing file keeps its permissions, the result is published atomically, and a symbolic link is refused rather than followed.
State of AGENTS.md | gitone agents | gitone agents update |
|---|---|---|
| missing | AGENT001 | creates the file with the marked block |
| ordinary file, no GitOne text | AGENT001 | appends the marked block after a blank line |
| the exact current block, unmarked | exit 0 | wraps that exact text in the markers |
| one marked block, current | exit 0 | nothing changes |
| one marked block, stale or edited by hand | AGENT001 | replaces only that block |
| other recognizable GitOne text | AGENT001 | AGENT001 |
| duplicate blocks, marked and unmarked text together, missing or repeated markers | AGENT001 | AGENT001 |
| a symbolic link or another non-regular file | AGENT001 | AGENT001 |
Before either command decides anything, AGENTS.md has to pass the ordinary
ownership rules: exactly one repository owns it, and it is
not protected, case-conflicting or otherwise unsafe. Those failures stay
PATH001, PATH002 and PATH003.
gitone agents # read-only check, exit 0 or 1
gitone agents update # write the block
gitone agents needs a valid configuration but no initialized repository,
takes no arguments or flags, changes nothing and has no --json mode.
gitone agents update is the explicit mutation: the command itself is the
authorization, so it has no confirmation, no --yes and needs no terminal.
Neither command edits .gitone.yml or .gitone.local.yml, stages, commits,
initializes, migrates or contacts a remote. Assign AGENTS.md yourself, then
run the update. Both are documented at
gitone.io/#agents.
What setup offers
Both setup modes ask about the block as the last optional question, before the summary and the confirmation:
- default
no, and anonever reserves or assignsAGENTS.md - skipped when the current block is already there
- a stale marked block is offered as
Update GitOne instructions?with defaultyes - text GitOne must not edit is reported and left alone, without a question
- the wizard gives a file it creates the only repository, or asks which
repository owns it; a private repository in
.gitone.local.ymlis a valid answer, so instructions never have to be published to be owned - template mode adds
AGENTS.mdto the placeholder repository, with a comment naming the private alternative - an existing configuration is never edited: the block is only offered when
exactly one repository already owns
AGENTS.md
The file is written only after the core setup result succeeded. If only that optional write fails, the finished project stays and the failure says so. Whenever the block is not added, setup prints the steps to add it later, including the assignment when no pattern owns the file yet.
The block
Copy this into your AGENTS.md, CLAUDE.md or equivalent, or let
gitone agents update keep it current:
This project uses GitOne: one working directory, several Git repositories,
ownership decided by path in `.gitone.yml`.
- Use `gitone`, never `git`, for status, staging, committing and pushing.
There is no `.git/` in the project root.
- `gitone status --json` is the machine-readable project state (schema
`version: 1`). Exit code 0 means ordinary changes, 1 means the `issues`
array explains why the project is unsafe.
- Available: `init`, `repo validate`, `repo list`, `status [--porcelain|--json]`,
`add [-A|-u|<path>...|.]`, `unstage [-A|<path>...|.]`,
`restore [--staged] <path>...`, `commit [<path>...] -m <message>`,
`commit [<path>...] -F -`, `diff [all|<repository>] [--staged]`,
`log [all|<repository>] [-n <count>]`, `branch [<name>]`, `switch <branch>`,
`fetch [all|<repository>]`, `pull [all|<repository>]`,
`push [all|<repository>] [--yes]`, `show --repository <name>
--source <head|index> -- <path>`, `vscode info --json`, `recover`, `abort`,
`doctor`, `agents`, `agents update`,
`vscode install [--vsix <path>] [--force]`. `gitone <command>
--help` prints the accepted syntax. Everything
else fails with `CLI001`; do not fall back to plain `git` to work around it.
- Never create, edit or commit `.gitone/` or `.gitone.local.yml`. Never move a
path across repository boundaries to make an error go away.
- A new file must be added to the `paths` of exactly one repository in
`.gitone.yml` (public) or `.gitone.local.yml` (private) before it can be
staged. `PATH001` means unassigned, `PATH002` means assigned twice.
- Repository boundaries are build boundaries. Before one file references
another - an import, an include, a build input - check whether both paths
have the same owner, and check the references you added again before you
hand the work back. A reference across repositories works in your working
directory and breaks in any checkout that has only one of them, such as CI.
GitOne never reads file contents, so it cannot catch this: report it and ask
a human rather than moving the path.
- `commit` with explicit paths commits the current working-tree version of
exactly those managed files, like native `git commit <path>`, and leaves
every other staged change staged. A file Git does not know yet has to be
staged once before it can be selected.
- A path covered by `rules.protected_paths` must never be staged or committed;
ownership does not override that hard deny.
- Valid patterns are exact paths such as `README.md`, `*` inside one path
segment and at most one `**` segment, as in `src/**`, `docs/**/*.md` or
`**/*.md`. Duplicate patterns are an error; overlapping ones are not, but a
concrete path matching two repositories is `PATH002`.
- Non-interactive pushes require `gitone push --yes`; without a terminal a
push fails with `PUSH001` instead of asking. Ask a human before pushing.
`push: disabled` repositories are never published, and a configured
`require_clean_worktree` rule must also pass.
- On `LOCK001` wait and retry. On `REC001`, `recover` finishes an interrupted
add, unstage, restore or commit and `abort` undoes it. For an interrupted push both
commands only report what reached the remotes. Never delete
`.gitone/recovery/` by hand.
- `gitone doctor` is read-only and safe to run at any time, including while
another operation holds the lock.